inst/shiny/dendroAnalystApp/app.R

# dendRoAnalyst Shiny application
# Place this file in: inst/shiny/dendroAnalystApp/app.R

options(shiny.maxRequestSize = 500 * 1024^2)

required_pkgs <- c("shiny", "bslib", "DT", "ggplot2", "tools", "shinyFiles")
missing_pkgs <- required_pkgs[!vapply(required_pkgs, requireNamespace, logical(1), quietly = TRUE)]
if (length(missing_pkgs) > 0) {
  stop("Please install required packages before running the app: ", paste(missing_pkgs, collapse = ", "), call. = FALSE)
}

suppressPackageStartupMessages({
  library(shiny)
  library(bslib)
  library(DT)
  library(ggplot2)
  library(shinyFiles)
})

`%||%` <- function(x, y) if (is.null(x) || length(x) == 0) y else x

app_pkg <- "dendRoAnalyst"

pkg_fun <- function(name) {
  if (exists(name, mode = "function", inherits = TRUE)) {
    return(get(name, mode = "function", inherits = TRUE))
  }
  if (requireNamespace(app_pkg, quietly = TRUE) && exists(name, envir = asNamespace(app_pkg), mode = "function", inherits = FALSE)) {
    return(get(name, envir = asNamespace(app_pkg), mode = "function", inherits = FALSE))
  }
  stop("Function '", name, "' was not found. Make sure ", app_pkg, " is loaded and the function is exported.", call. = FALSE)
}

has_fun <- function(name) {
  exists(name, mode = "function", inherits = TRUE) ||
    (requireNamespace(app_pkg, quietly = TRUE) && exists(name, envir = asNamespace(app_pkg), mode = "function", inherits = FALSE))
}

null_if_blank <- function(x) {
  if (is.null(x)) return(NULL)
  x <- trimws(as.character(x))
  if (!length(x) || !nzchar(x) || identical(tolower(x), "null")) return(NULL)
  x
}

parse_col <- function(x, default = NULL) {
  x <- null_if_blank(x)
  if (is.null(x)) return(default)
  n <- suppressWarnings(as.integer(x))
  if (!is.na(n) && grepl("^[0-9]+$", x)) return(n)
  x
}

parse_num_vec <- function(x, default = NULL) {
  x <- null_if_blank(x)
  if (is.null(x)) return(default)
  x <- gsub("c\\(|\\)", "", x)
  x <- gsub(";", ",", x)
  parts <- unlist(strsplit(x, "[,[:space:]]+"))
  parts <- parts[nzchar(parts)]
  out <- suppressWarnings(as.numeric(parts))
  out <- out[!is.na(out)]
  if (!length(out)) return(default)
  out
}

parse_int <- function(x, default = NULL) {
  out <- parse_num_vec(x, default = default)
  if (is.null(out)) return(default)
  as.integer(out[1])
}

parse_chr_vec <- function(x, default = NULL, allow_all = TRUE) {
  x <- null_if_blank(x)
  if (is.null(x)) return(default)
  if (allow_all && identical(tolower(x), "all")) return("all")
  x <- gsub("c\\(|\\)", "", x)
  x <- gsub("['\"]", "", x)
  parts <- unlist(strsplit(x, "[,;]+"))
  parts <- trimws(parts)
  parts <- parts[nzchar(parts)]
  if (!length(parts)) return(default)
  parts
}

parse_bool <- function(x) isTRUE(x)

parse_tree <- function(x) {
  x <- null_if_blank(x)
  if (is.null(x) || identical(tolower(x), "all")) return("all")
  n <- suppressWarnings(as.numeric(unlist(strsplit(gsub(";", ",", x), "[,[:space:]]+"))))
  n <- n[!is.na(n)]
  if (length(n) > 0) return(as.integer(n))
  parse_chr_vec(x, default = "all", allow_all = TRUE)
}

nice_name <- function(x) {
  x <- gsub("[^A-Za-z0-9_]+", "_", x)
  x <- gsub("_+", "_", x)
  x <- gsub("^_|_$", "", x)
  if (!nzchar(x)) x <- "result"
  x
}

first_table <- function(x) {
  if (is.data.frame(x)) return(x)
  if (is.list(x)) {
    preferred <- c(
      "data", "detrended_data", "standardized_data", "results", "summary",
      "daily_data", "ZG_phase", "ZG_cycle", "SC_phase", "SC_cycle",
      "fitted_data", "fit_statistics", "evaluation", "detrended_long",
      "parameters", "reconstructed_long", "reconstructed_wide",
      "trajectory_summary", "id_summary", "period_info", "gap_info",
      "assessment", "selected_ids", "phase_table", "long_data"
    )
    for (nm in preferred) {
      if (!is.null(x[[nm]]) && is.data.frame(x[[nm]])) return(x[[nm]])
    }
    dfs <- Filter(is.data.frame, x)
    if (length(dfs) > 0) return(dfs[[1]])
  }
  NULL
}

all_tables <- function(x) {
  if (is.data.frame(x)) return(list(data = x))
  if (!is.list(x)) return(list())
  out <- Filter(is.data.frame, x)
  if (length(out) == 0) {
    ft <- first_table(x)
    if (!is.null(ft)) out <- list(data = ft)
  }
  out
}

object_preview <- function(x, n = 5000) {
  tab <- first_table(x)
  if (is.null(tab)) return(NULL)
  tab <- as.data.frame(tab)
  if (nrow(tab) > n) tab <- tab[seq_len(n), , drop = FALSE]
  tab
}

# Keep component paths visible; never flatten a model into a misleading table.
export_tables <- function(x) {
  out <- list()
  visit <- function(value, path = "data", depth = 0L) {
    if (is.data.frame(value) || is.matrix(value)) {
      out[[length(out) + 1L]] <<- as.data.frame(value)
      names(out)[length(out)] <<- path
    } else if (is.list(value) && depth < 20L &&
               !inherits(value, c("ggplot", "lm", "glm", "nls"))) {
      for (i in seq_along(value)) {
        label <- names(value)[i]
        if (is.null(label) || is.na(label) || !nzchar(label)) label <- paste0("[[", i, "]]")
        visit(value[[i]], paste(path, label, sep = " / "), depth + 1L)
      }
    }
  }
  visit(x)
  names(out) <- make.unique(names(out))
  out
}

write_export_table <- function(tab, file, format) {
  if (any(vapply(tab, is.list, logical(1)))) {
    stop("This table contains list columns. Choose another component or save the complete result as RDS.", call. = FALSE)
  }
  if (format == "xlsx") {
    if (!requireNamespace("writexl", quietly = TRUE)) stop("Install writexl to export Excel files.", call. = FALSE)
    writexl::write_xlsx(tab, file)
  } else if (format == "tsv") {
    utils::write.table(tab, file, sep = "\t", row.names = FALSE, na = "")
  } else safe_write_csv(tab, file)
}

safe_write_csv <- function(x, file) {
  utils::write.csv(as.data.frame(x), file, row.names = FALSE, na = "")
}

save_plot <- function(expr, file, width = 10, height = 7, dpi = 160) {
  grDevices::png(filename = file, width = width, height = height, units = "in", res = dpi)
  on.exit(grDevices::dev.off(), add = TRUE)
  p <- expr()
  if (inherits(p, "ggplot")) {
    print(p)
  }
}

safe_call <- function(fun_name, args = list(), label = fun_name) {
  fun <- pkg_fun(fun_name)
  withCallingHandlers(
    tryCatch(
      do.call(fun, args),
      error = function(e) {
        stop("Error while running ", label, ": ", conditionMessage(e), call. = FALSE)
      }
    ),
    warning = function(w) {
      invokeRestart("muffleWarning")
    }
  )
}

read_uploaded_table <- function(path, sheet = NULL, sep = NULL, dec = NULL) {
  ext <- tolower(tools::file_ext(path))
  sep <- sep %||% NULL
  dec <- dec %||% "."
  if (ext %in% c("xlsx", "xls")) {
    if (!requireNamespace("readxl", quietly = TRUE)) stop("Package 'readxl' is required to read Excel files.")
    return(as.data.frame(readxl::read_excel(path, sheet = sheet %||% 1)))
  }
  if (is.null(sep)) sep <- guess_sep(path, ext)
  utils::read.table(path, header = TRUE, sep = sep, dec = dec, quote = "\"'", stringsAsFactors = FALSE,
                    check.names = FALSE, comment.char = "", na.strings = c("NA", ""))
}

# Detect the delimiter from the header line (comma, semicolon or tab; whitespace otherwise)
guess_sep <- function(path, ext = tolower(tools::file_ext(path))) {
  if (ext == "tsv") return("\t")
  first <- readLines(path, n = 1L, warn = FALSE)
  if (!length(first)) return("")
  counts <- vapply(c(",", ";", "\t"), function(s) lengths(regmatches(first, gregexpr(s, first, fixed = TRUE))), numeric(1))
  if (max(counts) == 0) return("")
  names(counts)[which.max(counts)]
}

call_read_dendrometer <- function(file, sep, dec, datetime_col, date_col, time_col, tz, sheet, range) {
  if (has_fun("read.dendrometer")) {
    out <- pkg_fun("read.dendrometer")(
      file = file,
      sep = null_if_blank(sep),
      dec = null_if_blank(dec),
      datetime_col = parse_col(datetime_col, 1),
      date_col = parse_col(date_col, NULL),
      time_col = parse_col(time_col, NULL),
      tz = tz,
      sheet = parse_col(sheet, NULL),
      range = null_if_blank(range),
      detect_resolution = TRUE,
      return_report = FALSE,
      quiet = TRUE
    )
    if (is.list(out) && !is.null(out$data)) return(out$data)
    return(out)
  }
  read_uploaded_table(file, sheet = parse_col(sheet, NULL), sep = null_if_blank(sep), dec = null_if_blank(dec))
}

call_read_climate <- function(file, sep, dec, time_col, vars, tz, sheet) {
  if (has_fun("read.climate")) {
    out <- pkg_fun("read.climate")(
      x = file,
      time_col = parse_col(time_col, NULL),
      vars = parse_chr_vec(vars, NULL),
      sep = null_if_blank(sep),
      dec = null_if_blank(dec),
      sheet = parse_col(sheet, 1),
      tz = tz,
      detect_resolution = TRUE,
      return_report = FALSE,
      verbose = FALSE
    )
    if (is.list(out) && !is.null(out$data)) return(out$data)
    return(out)
  }
  read_uploaded_table(file, sheet = parse_col(sheet, NULL), sep = null_if_blank(sep), dec = null_if_blank(dec))
}


# -------------------------------------------------------------------------
# Working-directory helpers
# -------------------------------------------------------------------------
app_supported_ext <- c("csv", "txt", "tsv", "xlsx", "xls", "rds", "rda", "rdata")

normalize_user_path <- function(path) {
  path <- null_if_blank(path)
  if (is.null(path)) return(NULL)
  path <- path.expand(path)
  normalizePath(path, winslash = "/", mustWork = FALSE)
}

wd_roots <- function() {
  roots <- c(
    Home = path.expand("~"),
    Current = getwd(),
    Temp = tempdir()
  )

  if (.Platform$OS.type == "windows") {
    drive_paths <- paste0(LETTERS, ":/")
    drive_paths <- drive_paths[dir.exists(drive_paths)]
    if (length(drive_paths)) {
      roots <- c(roots, stats::setNames(drive_paths, paste0("Drive ", substr(drive_paths, 1, 1))))
    }
  } else {
    extra <- c(Root = "/")
    for (d in c("/home", "/Users", "/mnt", "/media", "/Volumes")) {
      if (dir.exists(d)) extra <- c(extra, stats::setNames(d, basename(d)))
    }
    roots <- c(roots, extra)
  }

  roots <- roots[!is.na(roots) & nzchar(roots)]
  roots <- stats::setNames(normalizePath(roots, winslash = "/", mustWork = FALSE), names(roots))
  roots <- roots[!duplicated(roots)]
  roots[dir.exists(roots)]
}

wd_file_choices <- function(path) {
  path <- normalize_user_path(path)
  if (is.null(path) || !dir.exists(path)) return(c("Set working directory first" = ""))
  files <- list.files(path, pattern = paste0("\\.(", paste(app_supported_ext, collapse = "|"), ")$"), ignore.case = TRUE, full.names = FALSE)
  files <- sort(files)
  results <- file.path(path, "dendroAnalyst_results")
  if (dir.exists(results)) files <- c(files, file.path("dendroAnalyst_results",
    list.files(results, pattern = "\\.(rds|csv|tsv|xlsx)$", ignore.case = TRUE)))
  if (!length(files)) return(c("No supported files found" = ""))
  c("Choose file" = "", stats::setNames(files, files))
}

initial_app_wd <- function() {
  wd <- getOption("dendRoAnalyst.working_dir", getwd())
  wd <- normalize_user_path(wd)
  if (is.null(wd) || !dir.exists(wd)) wd <- normalize_user_path(getwd())
  wd
}

load_r_object_file <- function(path) {
  ext <- tolower(tools::file_ext(path))
  if (ext == "rds") return(readRDS(path))
  if (ext %in% c("rda", "rdata")) {
    env <- new.env(parent = emptyenv())
    nm <- load(path, envir = env)
    if (!length(nm)) stop("No object found inside ", basename(path), call. = FALSE)
    return(env[[nm[1]]])
  }
  stop("Not an R object file: ", basename(path), call. = FALSE)
}

ui <- bslib::page_navbar(
  title = tags$div(class = "dendro-brand", tags$img(src = "dendroanalyst-logo-wood.png", alt = "", class = "dendro-brand-icon"), tags$span("dendRoAnalyst", class = "dendro-brand-name")),
  theme = bslib::bs_theme(
    version = 5,
    bootswatch = "minty",
    primary = "#795238",
    secondary = "#8a6040",
    success = "#795238",
    base_font = "sans-serif",
    heading_font = "sans-serif"
  ),
  header = tags$head(tags$link(rel = "stylesheet", type = "text/css", href = "custom.css?v=5")),

  bslib::nav_panel(
    "0. Settings",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        h4("Working directory"),
        helpText("* Required input for the selected operation. Some required inputs have defaults."),
        tags$details(tags$summary("Plot size and text (all tabs)"),
          numericInput("plot_width", "Width (inches) *", 10, min = 2, max = 30),
          numericInput("plot_height", "Height (inches) *", 7, min = 2, max = 30),
          numericInput("plot_dpi", "Export resolution (DPI) *", 160, min = 72, max = 600),
          numericInput("plot_font", "Font size (points) *", 12, min = 6, max = 36),
          helpText("Previews use 96 DPI. PNG downloads and saved plots use the export DPI.")),
        shinyFiles::shinyDirButton(
          id = "browse_wd",
          label = "Browse folders...",
          title = "Select working directory",
          buttonType = "default",
          class = "btn-outline-primary w-100"
        ),
        tags$small(class = "text-muted", "The folder browser shows folders on the machine where the Shiny app is running."),
        tags$hr(),
        textInput("wd_path", "Selected folder path *", value = initial_app_wd(), width = "100%"),
        checkboxInput("wd_create", "Create folder if it does not exist", TRUE),
        checkboxInput("wd_set_session", "Also call setwd() for this R session", FALSE),
        actionButton("set_wd", "Set as working directory", class = "btn-success w-100"),
        tags$hr(),
        actionButton("refresh_wd_files", "Refresh file list", class = "btn-outline-secondary w-100")
      ),
      bslib::layout_columns(
        col_widths = c(5, 7),
        bslib::card(
          class = "shadow-card",
          bslib::card_header("Current working directory"),
          verbatimTextOutput("wd_status"),
          tags$hr(),
          strong("Results folder"),
          verbatimTextOutput("wd_results_dir")
        ),
        bslib::card(
          class = "shadow-card",
          bslib::card_header("Files available in working directory"),
          DTOutput("wd_files_table")
        )
      )
    )
  ),

  bslib::nav_panel(
    "1. Data",
    tags$div(class = "data-import-grid",
      bslib::card(
        class = "shadow-card data-import-card", fill = FALSE,
        bslib::card_header("Dendrometer data *"),
        fileInput("dm_file", "Upload dendrometer file", accept = c(".csv", ".txt", ".tsv", ".xlsx", ".xls")),
        tags$div(class = "data-fields",
          textInput("dm_sep", "Delimiter", value = "", placeholder = "auto"),
          textInput("dm_dec", "Decimal mark", value = "", placeholder = "auto"),
          textInput("dm_tz", "Time zone", value = "UTC")
        ),
        tags$div(class = "data-fields",
          textInput("dm_datetime_col", "Datetime column", value = "1"),
          textInput("dm_date_col", "Date column", value = ""),
          textInput("dm_time_col", "Time column", value = "")
        ),
        tags$div(class = "data-fields",
          textInput("dm_sheet", "Excel sheet", value = ""),
          textInput("dm_range", "Excel range", value = "")
        ),
        tags$hr(),
        selectInput("wd_dm_file", "Or choose existing file from working directory", choices = c("Set working directory first" = "")),
        actionButton("load_wd_dm", "Load selected dendrometer file", class = "btn-outline-success w-100"),
        tags$hr(),
        actionButton("read_dm", "Load uploaded dendrometer data", class = "btn-success w-100")
      ),
      bslib::card(
        class = "shadow-card data-import-card", fill = FALSE,
        bslib::card_header("Climate data"),
        fileInput("clim_file", "Upload climate file", accept = c(".csv", ".txt", ".tsv", ".xlsx", ".xls")),
        tags$div(class = "data-fields",
          textInput("clim_sep", "Delimiter", value = "", placeholder = "auto"),
          textInput("clim_dec", "Decimal mark", value = "", placeholder = "auto"),
          textInput("clim_tz", "Time zone", value = "UTC")
        ),
        tags$div(class = "data-fields",
          textInput("clim_time_col", "Time column", value = ""),
          textInput("clim_vars", "Climate variables", value = "", placeholder = "temp,VPD,prec")
        ),
        textInput("clim_sheet", "Excel sheet", value = "1"),
        tags$hr(),
        selectInput("wd_clim_file", "Or choose existing file from working directory", choices = c("Set working directory first" = "")),
        actionButton("load_wd_clim", "Load selected climate file", class = "btn-outline-success w-100"),
        tags$hr(),
        actionButton("read_clim", "Load uploaded climate data", class = "btn-success w-100")
      ),
      bslib::card(
        class = "shadow-card data-import-card", fill = FALSE,
        bslib::card_header("Reference network / metadata"),
        fileInput("ref_file", "Reference dendrometer network *", accept = c(".csv", ".txt", ".tsv", ".xlsx", ".xls")),
        selectInput("wd_ref_file", "Or choose reference file from working directory", choices = c("Set working directory first" = "")),
        actionButton("load_wd_ref", "Load selected reference file", class = "btn-outline-success w-100"),
        actionButton("read_ref", "Load uploaded reference network", class = "btn-outline-success w-100"),
        tags$hr(),
        fileInput("meta_file", "Tree metadata for grouping", accept = c(".csv", ".txt", ".tsv", ".xlsx", ".xls")),
        selectInput("wd_meta_file", "Or choose metadata file from working directory", choices = c("Set working directory first" = "")),
        actionButton("load_wd_meta", "Load selected metadata file", class = "btn-outline-success w-100"),
        actionButton("read_meta", "Load uploaded metadata", class = "btn-outline-success w-100")
      )
    ),
    tags$div(class = "data-preview-grid",
      bslib::card(bslib::card_header("Dendrometer preview"), DTOutput("dm_preview")),
      bslib::card(bslib::card_header("Climate preview"), DTOutput("clim_preview"))
    )
  ),

  bslib::nav_panel(
    "2. Preprocess",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        selectInput("pre_source", "Input data *", choices = c("Upload dendrometer data first" = "__none__")),
        actionButton("detect_resolution", "Detect resolution", class = "btn-outline-primary w-100"),
        verbatimTextOutput("resolution_txt"),
        tags$hr(),
        h5("Truncate"),
        textInput("truncate_year", "Calendar year(s)", value = "", placeholder = "2017 or 2017,2019"),
        textInput("truncate_doy", "DOY window", value = "", placeholder = "120,300"),
        actionButton("run_truncate", "Run truncation", class = "btn-success w-100"),
        tags$hr(),
        h5("Resample"),
        textInput("resample_by", "Target resolution *", value = "D", placeholder = "30M, H, D, W, ME"),
        selectInput("resample_value", "Aggregation", choices = c("mean", "max", "min", "sum", "count")),
        selectInput("resample_method", "Method", choices = c("auto", "aggregate", "interpolate")),
        actionButton("run_resample", "Run resampling", class = "btn-success w-100"),
        tags$hr(),
        h5("Missing values"),
        numericInput("na_resolution", "Resolution in minutes *", value = 60, min = 1),
        checkboxInput("na_fill", "Fill missing values", TRUE),
        selectInput("na_method", "Interpolation method", choices = c("spline", "seasonal")),
        checkboxInput("na_assess", "Assess interpolation", FALSE),
        actionButton("run_na", "Detect/interpolate gaps", class = "btn-success w-100"),
        tags$hr(),
        h5("Network interpolation"),
        helpText("Load a reference dendrometer network in 1. Data. The Input data selected above is the series to fill."),
        selectInput("network_method", "Network method", choices = c("proportional", "linear")),
        numericInput("network_boot", "Bootstrap samples", value = 100, min = 1, step = 1),
        checkboxInput("network_pi", "Calculate prediction intervals", TRUE),
        checkboxInput("network_assess", "Assess network interpolation", FALSE),
        conditionalPanel("input.network_assess", textInput("network_lengths", "Assessment gap lengths (steps)", value = "1,2,4")),
        checkboxInput("network_correct_jumps", "Correct jumps at filled gaps", FALSE),
        conditionalPanel("input.network_correct_jumps", numericInput("network_jump_threshold", "Gap jump threshold", value = 0.05, min = 0.001, step = 0.01)),
        actionButton("run_network", "Run network interpolation", class = "btn-success w-100"),
        tags$hr(),
        h5("Jump adjustment"),
        selectInput("jump_detection", "Detection method", choices = c("manual", "auto")),
        conditionalPanel("input.jump_detection === 'manual'", numericInput("jump_threshold", "Manual threshold", value = 1, min = 0, step = 0.01)),
        conditionalPanel("input.jump_detection === 'auto'", numericInput("jump_penalty", "Auto penalty", value = 10, min = 0.01, step = 1)),
        selectInput("jump_adjustment", "Adjustment method", choices = c("window_median", "point_diff")),
        numericInput("jump_window", "Adjustment window", value = 5, min = 1, step = 1),
        checkboxInput("jump_every_second", "Auto: keep every second changepoint", TRUE),
        checkboxInput("jump_set_na", "Set exact jump point to NA", FALSE),
        actionButton("run_jump_adjust", "Adjust jumps", class = "btn-success w-100"),
        tags$hr(),
        h5("Standardize"),
        selectInput("std_season", "Season type", choices = c("NH", "SH", "CS")),
        conditionalPanel("input.std_season === 'CS'", textInput("std_cs", "Custom season DOYs", value = "", placeholder = "100,280")),
        selectInput("std_method", "Method", choices = c("center", "amplitude", "robust_amplitude", "minmax", "zscore", "robust_zscore", "percentile")),
        actionButton("run_standardize", "Standardize", class = "btn-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_pre_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_pre_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_pre_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::card(
        bslib::card_header("Preprocessing output"),
        DTOutput("pre_table"),
        tags$hr(),
        plotOutput("pre_plot", height = 440),
        downloadButton("download_pre_plot", "Download current plot")
      )
    )
  ),

  bslib::nav_panel(
    "3. Daily statistics",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        selectInput("daily_source", "Input data *", choices = c("Upload dendrometer data first" = "__none__")),
        textInput("daily_tree", "TreeNum *", value = "1", placeholder = "1, 1:3, or all"),
        actionButton("run_daily", "Calculate daily.data()", class = "btn-success w-100"),
        tags$hr(),
        selectInput("daily_plot_type", "Plot type", choices = c("summary", "amplitude", "minmax_ribbon", "timing", "timing_violin", "lag", "change", "boxplot", "heatmap")),
        conditionalPanel("['boxplot','heatmap'].includes(input.daily_plot_type)", selectInput("daily_stat", "Statistic", choices = c("amplitude", "Max_diff", "mean", "median", "Max", "Min", "lag_h", "Time_min_h", "Time_max_h"))),
        conditionalPanel("input.daily_plot_type === 'boxplot'", selectInput("daily_by", "Boxplot grouping", choices = c("month", "month_of_year", "year"))),
        conditionalPanel("input.daily_plot_type === 'boxplot'", selectInput("daily_box_style", "Box style", choices = c("boxplot", "violin", "both"))),
        textInput("daily_year", "Year filter", value = ""),
        textInput("daily_doy", "DOY filter", value = ""),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_daily_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_daily_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_daily_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::layout_columns(
        col_widths = c(6, 6),
        bslib::card(bslib::card_header("Daily table"), DTOutput("daily_table")),
        bslib::card(
          bslib::card_header("Daily plot"),
          plotOutput("daily_plot", height = 520),
          downloadButton("download_daily_plot", "Download plot")
        )
      )
    )
  ),

  bslib::nav_panel(
    "4. Phase analysis",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        selectInput("phase_source", "Input data *", choices = c("Upload dendrometer data first" = "__none__")),
        radioButtons("phase_method", "Method", choices = c("Zero-growth: phase.zg" = "zg", "Stem-cycle: phase.sc" = "sc"), inline = FALSE),
        textInput("phase_tree", "TreeNum *", value = "1"),
        numericInput("phase_smoothing", "Smoothing hours (blank/0 = none)", value = 0, min = 0),
        conditionalPanel("input.phase_method === 'zg'", numericInput("phase_beta", "ZG beta for ABr", value = 1, min = 0, step = 0.05)),
        actionButton("run_phase", "Run phase analysis", class = "btn-success w-100"),
        tags$hr(),
        uiOutput("phase_plot_controls"),
        textInput("phase_year", "Year filter", value = ""),
        textInput("phase_doy", "DOY filter", value = ""),
        tags$hr(),
        numericInput("twd_smoothing", "twd.maxima smoothing", value = 5, min = 1, max = 24),
        actionButton("run_twd_maxima", "Run twd.maxima()", class = "btn-outline-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_phase_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_phase_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_phase_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::layout_columns(
        col_widths = c(6, 6),
        bslib::card(bslib::card_header("Phase table"), DTOutput("phase_table")),
        bslib::card(
          bslib::card_header("Phase plot"),
          plotOutput("phase_plot", height = 550),
          tableOutput("phase_scatter_stats"),
          downloadButton("download_phase_plot", "Download plot")
        )
      )
    )
  ),

  bslib::nav_panel(
    "5. Growth models",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        selectInput("growth_source", "Input data *", choices = c("Upload dendrometer data first" = "__none__")),
        selectInput("growth_engine", "Task", choices = c("Single growth fit" = "single", "Double growth fit" = "double", "Compare methods" = "evaluate")),
        textInput("growth_tree", "TreeNum *", value = "all"),
        conditionalPanel("input.growth_engine === 'single'", selectInput("growth_method", "Single method", choices = c("gam", "gompertz", "logistic", "richards", "loess", "spline"))),
        conditionalPanel("input.growth_engine === 'double'", selectInput("growth_double_method", "Double method", choices = c("gompertz", "richards"))),
        conditionalPanel("input.growth_engine === 'evaluate'", checkboxGroupInput("growth_eval_methods", "Methods to compare", choices = c("gam", "gompertz", "logistic", "richards", "loess", "spline", "double_gompertz", "double_richards"), selected = c("gam", "gompertz", "loess", "spline"))),
        selectInput("growth_year_mode", "Year mode", choices = c("yearly", "pooled")),
        textInput("growth_years", "Vegetation seasons", value = "all"),
        checkboxInput("growth_fit_gro", "Fit cumulative GRO", TRUE),
        selectInput("growth_site_mode", "Season mode", choices = c("NH", "SH", "CS")),
        conditionalPanel("input.growth_site_mode === 'CS'", textInput("growth_custom_season", "Custom season DOYs", value = "275,274")),
        textInput("growth_fraction", "Growth fraction", value = "0.1,0.9"),
        numericInput("growth_min_unique", "Minimum unique growth", value = 10, min = 3),
        numericInput("growth_rate_threshold", "Rate threshold fraction", value = 0.1, min = 0, max = 1, step = 0.05),
        checkboxInput("growth_fix_a", "Fix asymptote to observed maximum", FALSE),
        actionButton("run_growth", "Run growth task", class = "btn-success w-100"),
        tags$hr(),
        selectInput("growth_plot_type", "Growth plot type", choices = c("fit", "season", "residuals", "timing", "overlay", "parameters", "boxplot", "mean", "heatmap")),
        selectInput("growth_metric", "Evaluation metric", choices = c("rmse", "mae", "bias", "abs_bias", "r2", "correlation", "nrmse", "rss", "aic_approx", "bic_approx")),
        selectInput("growth_x_axis", "X axis", choices = c("default", "season_day", "doy", "date")),
        tags$hr(),
        selectInput("detrend_object", "Growth-fit object to detrend", choices = c()),
        actionButton("run_detrend", "Detrend selected fit", class = "btn-outline-success w-100"),
        actionButton("run_mean_detrended", "Mean detrended series", class = "btn-outline-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_growth_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_growth_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_growth_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::layout_columns(
        col_widths = c(6, 6),
        bslib::card(bslib::card_header("Growth result table"), DTOutput("growth_table")),
        bslib::card(
          bslib::card_header("Growth plot"),
          plotOutput("growth_plot", height = 550),
          downloadButton("download_growth_plot", "Download plot")
        )
      )
    )
  ),

  bslib::nav_panel(
    "6. Climate",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        h5("Climate feature engineering"),
        textInput("daily_mean_vars", "Daily mean vars", value = ""),
        textInput("daily_min_vars", "Daily min vars", value = ""),
        textInput("daily_max_vars", "Daily max vars", value = ""),
        textInput("daily_sum_vars", "Daily sum vars", value = ""),
        textInput("daily_median_vars", "Daily median vars", value = ""),
        actionButton("run_daily_clim", "Create daily climate", class = "btn-success w-100"),
        tags$hr(),
        textInput("sub_mean_vars", "Subdaily mean vars", value = ""),
        textInput("sub_sum_vars", "Subdaily sum vars", value = ""),
        textInput("sub_lag_vars", "Subdaily lag vars", value = ""),
        textInput("sub_roll_hours", "Rolling windows (h)", value = "3,6,24"),
        actionButton("run_subdaily_clim", "Create subdaily climate", class = "btn-success w-100"),
        tags$hr(),
        h5("Attach climate"),
        selectInput("clim_attach_object", "Dendrometer result object", choices = c()),
        selectInput("clim_attach_scale", "Scale", choices = c("auto", "daily", "phase", "subdaily")),
        actionButton("run_add_climate", "Attach climate", class = "btn-success w-100"),
        tags$hr(),
        h5("Adverse climate / TWD"),
        selectInput("climtwd_source", "Dendrometer data *", choices = c("Upload dendrometer data first" = "__none__")),
        selectInput("climtwd_daily_value", "Daily dendrometer value", choices = c("max", "mean", "min", "median")),
        textInput("climtwd_var", "Climate variable *", value = "", placeholder = "e.g. prec (blank = first variable)"),
        selectInput("climtwd_clim_fun", "Daily climate summary", choices = c("sum", "mean", "min", "max", "median")),
        textInput("climtwd_threshold_clim", "Climate threshold *", value = "<10"),
        textInput("climtwd_threshold_days", "Duration threshold *", value = ">5"),
        actionButton("run_clim_twd", "Run clim.twd()", class = "btn-success w-100"),
        selectInput("climtwd_stats_object", "clim.twd object *", choices = c()),
        selectInput("climtwd_group", "Group by", choices = c("tree", "species", "site", "species_site")),
        actionButton("run_climtwd_stats", "Summarize clim.twd", class = "btn-outline-success w-100"),
        tags$hr(),
        h5("Running correlation"),
        selectInput("movcor_source", "Dendrometer data *", choices = c("Upload dendrometer data first" = "__none__")),
        textInput("movcor_tree", "TreeNum *", value = "1"),
        numericInput("movcor_win", "Window size", value = 21, min = 18),
        selectInput("movcor_method", "Correlation", choices = c("pearson", "kendall", "spearman")),
        textInput("movcor_vars", "Climate vars (blank = default)", value = ""),
        actionButton("run_movcor", "Run mov.cor.dm()", class = "btn-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_climate_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_climate_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_climate_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::layout_columns(
        col_widths = c(6, 6),
        bslib::card(
          bslib::card_header("Climate result table"),
          DTOutput("climate_table")
        ),
        bslib::card(
          bslib::card_header("Climate plot"),
          selectInput("climate_plot_object", "Object to plot *", choices = c()),
          bslib::layout_columns(
            textInput("climate_plot_var", "Climate variable", value = ""),
            textInput("climate_metric_var", "Metric variable", value = "")
          ),
          selectInput("climate_plot_type", "Plot type", choices = c("timeseries", "boxplot", "violin", "both", "relation", "cor_heatmap", "regression_heatmap")),
          actionButton("refresh_climate_plot", "Refresh plot", class = "btn-outline-primary"),
          plotOutput("climate_plot", height = 480),
          downloadButton("download_climate_plot", "Download plot")
        )
      )
    )
  ),

  bslib::nav_panel(
    "7. Wavelets",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        h5("Wavelet analysis"),
        selectInput("wave_source", "Dendrometer source *", choices = c("Upload dendrometer data first" = "__none__")),
        textInput("wave_tree", "TreeNum *", value = "all"),
        selectInput("wave_input_source", "Series source", choices = c("auto", "raw", "detrended", "first_diff")),
        selectInput("wave_na", "NA handling", choices = c("interpolate", "fail")),
        numericInput("wave_loess", "LOESS span", value = 0, min = 0, max = 1, step = 0.05),
        numericInput("wave_dj", "Frequency resolution dj", value = 1/20, min = 0.001, step = 0.001),
        textInput("wave_lower", "Lower period (native units)", value = ""),
        textInput("wave_upper", "Upper period (native units)", value = ""),
        checkboxInput("wave_pval", "Monte Carlo significance", FALSE),
        conditionalPanel("input.wave_pval", numericInput("wave_nsim", "Simulations", value = 10, min = 1)),
        actionButton("run_wavelet", "Run dm_wavelet()", class = "btn-success w-100"),
        tags$hr(),
        selectInput("wave_object", "Wavelet object *", choices = c()),
        selectInput("wave_plot_type", "Plot type", choices = c("power", "average", "series", "coherence", "cross_power", "average_coherence", "average_cross_power", "phase")),
        actionButton("refresh_wave_plot", "Refresh plot", class = "btn-outline-primary w-100"),
        tags$hr(),
        h5("Reconstruct/remove cycles"),
        selectInput("recon_mode", "Mode", choices = c("extract", "remove")),
        textInput("recon_series", "Series", value = "", placeholder = "blank = all"),
        textInput("recon_periods", "Exact period hours", value = "24", placeholder = "24,168"),
        textInput("recon_lower", "Lower hours (with upper: replaces exact periods)", value = ""),
        textInput("recon_upper", "Upper hours", value = ""),
        actionButton("run_reconstruct", "Run reconstruction", class = "btn-success w-100"),
        tags$hr(),
        h5("Wavelet coherence"),
        textInput("coh_clim_vars", "Climate vars *", value = "all"),
        actionButton("run_coherence", "Run coherence", class = "btn-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_wave_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_wave_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_wave_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::layout_columns(
        col_widths = c(6, 6),
        bslib::card(bslib::card_header("Wavelet result table"), DTOutput("wave_table")),
        bslib::card(
          bslib::card_header("Wavelet plot"),
          plotOutput("wave_plot", height = 540),
          downloadButton("download_wave_plot", "Download plot")
        )
      )
    )
  ),

  bslib::nav_panel(
    "8. Advanced runner",
    bslib::layout_sidebar(
      sidebar = bslib::sidebar(
        selectInput("adv_function", "Function *", choices = c(
          "daily.data", "dendro.resample", "dendro.truncate", "dm.na.interpolation", "network.interpolation",
          "phase.zg", "phase.sc", "twd.maxima", "dm.growth.fit", "dm.growth.fit.double", "dm.growth.evaluate",
          "dm.detrend.fit", "mean_detrended.dm", "dm_standardize", "clim.twd", "clim.twd.stats",
          "mov.cor.dm", "dm_wavelet", "dm_wavelet_reconstruct", "dm_wavelet_coherence", "dm_event_times",
          "dm_event_climate", "dm_add_climate", "dm_plot_climate", "jump.locator", "smooth_dm", "reso_dm",
          "dm_epoch_extract", "dm_epoch_test", "dm_event_climate_summary", "dm_event_climate_test",
          "clim.twd.test", "dm.fit.gompertz", "dm_daily_clim", "dm_subdaily_clim",
          "dm_join_daily_clim", "dm_join_subdaily_clim", "dm_join_phase_clim"
        )),
        selectInput("adv_source", "Primary input *", choices = c("Uploaded dendrometer data" = "__dm__")),
        checkboxInput("adv_use_climate", "Pass uploaded climate data if argument exists", TRUE),
        checkboxInput("adv_use_reference", "Pass reference network if argument exists", TRUE),
        verbatimTextOutput("adv_required"),
        tags$label("Extra arguments as R list items"),
        tags$textarea(id = "adv_args", class = "form-control", rows = 10,
          placeholder = "Example:\nTreeNum = 1, smoothing = 5\n\nDo not include list(...)."),
        actionButton("run_advanced", "Run selected function", class = "btn-success w-100"),
        tags$hr(),
        h5("Display and save"),
        actionButton("show_advanced_data", "Show data", class = "btn-outline-primary w-100"),
        actionButton("show_advanced_plot", "Show plot", class = "btn-outline-primary w-100"),
        actionButton("save_advanced_tab", "Save result to working directory", class = "btn-outline-success w-100")
      ),
      bslib::card(
        bslib::card_header("Advanced result"),
        DTOutput("advanced_table"),
        tags$hr(),
        plotOutput("advanced_plot", height = 480),
        downloadButton("download_advanced_plot", "Download plot")
      )
    )
  ),

  bslib::nav_panel(
    "9. Results & export",
    tags$div(class = "results-workspace",
      tags$div(class = "results-controls",
        bslib::card(fill = FALSE,
          bslib::card_header("1. Load and select a result"),
          selectInput("saved_result", "Saved RDS in working directory", choices = character()),
          actionButton("reload_result", "Load saved result"),
          selectInput("result_object", "Selected result *", choices = character()),
          helpText("Choose a result from this session or load an RDS file. The same selection is used for object and table exports."),
          actionButton("save_result", "Save complete result", class = "btn-success w-100"),
          downloadButton("download_rds", "Download complete RDS"),
          tags$details(tags$summary("Other exports and session controls"),
            downloadButton("download_csv", "Download primary table CSV"),
            downloadButton("download_xlsx", "Download all tables XLSX"),
            downloadButton("download_all_zip", "Download all results ZIP"),
            actionButton("clear_results", "Clear stored results", class = "btn-outline-danger w-100"))
        ),
        bslib::card(fill = FALSE,
          bslib::card_header("2. Export a table"),
          selectInput("export_component", "Table within result *", choices = character()),
          selectInput("export_format", "File format *", choices = c("CSV" = "csv", "Tab-separated" = "tsv", "Excel" = "xlsx")),
          textInput("export_filename", "File name (without extension) *", "exported_table"),
          downloadButton("download_selected_table", "Download selected table"),
          actionButton("save_selected_table", "Save table to working directory", class = "btn-success w-100"),
          helpText("Nested tables are listed by component path. Exports contain all rows; the preview shows up to 5,000. Files are saved under dendroAnalyst_results."),
          verbatimTextOutput("export_status")
        )
      ),
      tags$div(class = "results-previews",
        bslib::card(fill = FALSE, bslib::card_header("Selected table preview"), DTOutput("export_preview")),
        bslib::card(fill = FALSE, bslib::card_header("Complete object structure"), verbatimTextOutput("object_summary")),
        bslib::card(fill = FALSE, bslib::card_header("Activity log"), verbatimTextOutput("activity_log"))
      )
    )
  )
)

server <- function(input, output, session) {
  rv <- reactiveValues(
    dm = NULL,
    clim = NULL,
    ref = NULL,
    meta = NULL,
    objects = list(),
    current_pre = NULL,
    current_daily = NULL,
    current_phase = NULL,
    current_growth = NULL,
    current_climate = NULL,
    current_wave = NULL,
    current_advanced = NULL,
    log = character(0),
    files_version = 0L,
    working_dir = initial_app_wd(),
    results_dir = file.path(initial_app_wd(), "dendroAnalyst_results")
  )

  log_msg <- function(msg, type = "message") {
    rv$log <- c(sprintf("[%s] %s", format(Sys.time(), "%Y-%m-%d %H:%M:%S"), msg), rv$log)
    showNotification(msg, type = type, duration = if (type == "error") 8 else 4)
  }

  export_components <- reactive({
    req(input$result_object)
    export_tables(rv$objects[[input$result_object]])
  })
  observe({
    choices <- names(export_components())
    updateSelectInput(session, "export_component", choices = choices)
  })
  export_table <- reactive({
    tabs <- export_components()
    validate(need(length(tabs) > 0, "This result contains no exportable tables."))
    req(input$export_component)
    validate(need(input$export_component %in% names(tabs), "Choose a table within the result."))
    tabs[[input$export_component]]
  })
  output$export_status <- renderText({
    tab <- export_table()
    paste(nrow(tab), "rows x", ncol(tab), "columns")
  })
  output$export_preview <- renderDT({
    datatable(utils::head(export_table(), 5000), options = list(scrollX = TRUE, pageLength = 10))
  })
  output$download_selected_table <- downloadHandler(
    filename = function() paste0(nice_name(input$export_filename), ".", input$export_format),
    content = function(file) write_export_table(export_table(), file, input$export_format)
  )
  observeEvent(input$save_selected_table, {
    tryCatch({
      tab <- export_table()
      path <- tempfile(pattern = paste0(nice_name(input$export_filename), "_"),
                       tmpdir = ensure_results_dir(), fileext = paste0(".", input$export_format))
      write_export_table(tab, path, input$export_format)
      rv$files_version <- rv$files_version + 1L
      log_msg(paste("Saved selected table:", path))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })



  plot_settings <- reactive({
    values <- c(width = input$plot_width %||% 10, height = input$plot_height %||% 7,
                dpi = input$plot_dpi %||% 160, font = input$plot_font %||% 12)
    validate(need(all(is.finite(values)) && all(values >= c(2,2,72,6)) &&
                    all(values <= c(30,30,600,36)), "Choose plot settings within the displayed limits."))
    validate(need(values[1]*values[2]*values[3]^2 <= 5e7, "Reduce dimensions or DPI: maximum 50 million pixels."))
    values
  })
  draw_plot <- function(fun) {
    size <- plot_settings()[["font"]]
    old <- par(cex = size / 12)
    on.exit(par(old), add = TRUE)
    p <- fun()
    if (inherits(p, "ggplot")) {
      p <- p + theme(text = element_text(size = size),
                     axis.text = element_text(size = size), axis.title = element_text(size = size),
                     legend.text = element_text(size = size), legend.title = element_text(size = size),
                     strip.text = element_text(size = size), plot.title = element_text(size = size * 1.2),
                     plot.subtitle = element_text(size = size))
      print(p)
    }
    invisible(p)
  }
  save_plot <- function(expr, file) {
    cfg <- plot_settings()
    png(file, width = cfg[["width"]], height = cfg[["height"]], units = "in", res = cfg[["dpi"]])
    on.exit(dev.off(), add = TRUE)
    draw_plot(expr)
  }

  root_paths <- wd_roots()

  shinyFiles::shinyDirChoose(
    input,
    id = "browse_wd",
    roots = root_paths,
    session = session,
    restrictions = system.file(package = "base")
  )

  observeEvent(input$browse_wd, {
    path <- shinyFiles::parseDirPath(root_paths, input$browse_wd)
    if (length(path) && nzchar(path)) {
      updateTextInput(session, "wd_path", value = normalize_user_path(path))
    }
  }, ignoreInit = TRUE)

  ensure_results_dir <- function() {
    if (is.null(rv$working_dir) || !dir.exists(rv$working_dir)) {
      stop("Set a valid working directory first.", call. = FALSE)
    }
    rv$results_dir <- file.path(rv$working_dir, "dendroAnalyst_results")
    if (!dir.exists(rv$results_dir)) dir.create(rv$results_dir, recursive = TRUE, showWarnings = FALSE)
    rv$results_dir
  }

  observeEvent(input$set_wd, {
    tryCatch({
      path <- normalize_user_path(input$wd_path)
      if (is.null(path)) stop("Choose or type a folder path first.", call. = FALSE)
      if (!dir.exists(path) && isTRUE(input$wd_create)) dir.create(path, recursive = TRUE, showWarnings = FALSE)
      if (!dir.exists(path)) stop("Directory does not exist: ", path, call. = FALSE)
      rv$working_dir <- normalizePath(path, winslash = "/", mustWork = TRUE)
      rv$results_dir <- file.path(rv$working_dir, "dendroAnalyst_results")
      dir.create(rv$results_dir, recursive = TRUE, showWarnings = FALSE)
      if (isTRUE(input$wd_set_session)) setwd(rv$working_dir)
      updateTextInput(session, "wd_path", value = rv$working_dir)
      log_msg(paste("Working directory set to", rv$working_dir), "message")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$refresh_wd_files, {
    log_msg("Working-directory file list refreshed.", "message")
  })

  output$wd_status <- renderText({
    paste0(
      "Current app working directory:\n", rv$working_dir %||% "Not set", "\n\n",
      "R session getwd():\n", getwd()
    )
  })

  output$wd_results_dir <- renderText({
    ensure <- tryCatch(ensure_results_dir(), error = function(e) rv$results_dir %||% "Not set")
    ensure
  })

  output$wd_files_table <- renderDT({
    input$refresh_wd_files
    path <- rv$working_dir
    if (is.null(path) || !dir.exists(path)) return(datatable(data.frame(message = "Set a working directory first.")))
    files <- list.files(path, pattern = paste0("\\.(", paste(app_supported_ext, collapse = "|"), ")$"), ignore.case = TRUE, full.names = TRUE)
    if (!length(files)) return(datatable(data.frame(message = "No supported files found in the selected folder.")))
    info <- file.info(files)
    dat <- data.frame(
      file = basename(files),
      size_kb = round(info$size / 1024, 1),
      modified = info$mtime,
      stringsAsFactors = FALSE
    )
    datatable(dat, options = list(scrollX = TRUE, pageLength = 10))
  })

  observe({
    input$refresh_wd_files
    rv$files_version
    choices <- wd_file_choices(rv$working_dir)
    saved <- choices[grepl("\\.rds$", choices, ignore.case = TRUE)]
    updateSelectInput(session, "saved_result", choices = saved)
    for (id in c("wd_dm_file", "wd_clim_file", "wd_ref_file", "wd_meta_file")) {
      updateSelectInput(session, id, choices = choices)
    }
  })

  load_working_file <- function(file_name, kind = c("dm", "clim", "ref", "meta")) {
    kind <- match.arg(kind)
    if (is.null(rv$working_dir) || !dir.exists(rv$working_dir)) stop("Set the working directory first.", call. = FALSE)
    file_name <- null_if_blank(file_name)
    if (is.null(file_name)) stop("Choose a file from the working directory first.", call. = FALSE)
    path <- normalizePath(file.path(rv$working_dir, file_name), winslash = "/", mustWork = TRUE)
    ext <- tolower(tools::file_ext(path))

    if (ext %in% c("rds", "rda", "rdata")) {
      obj <- load_r_object_file(path)
    } else if (identical(kind, "dm")) {
      obj <- call_read_dendrometer(
        file = path,
        sep = input$dm_sep,
        dec = input$dm_dec,
        datetime_col = input$dm_datetime_col,
        date_col = input$dm_date_col,
        time_col = input$dm_time_col,
        tz = input$dm_tz,
        sheet = input$dm_sheet,
        range = input$dm_range
      )
    } else if (identical(kind, "clim")) {
      obj <- call_read_climate(
        file = path,
        sep = input$clim_sep,
        dec = input$clim_dec,
        time_col = input$clim_time_col,
        vars = input$clim_vars,
        tz = input$clim_tz,
        sheet = input$clim_sheet
      )
    } else {
      obj <- read_uploaded_table(path)
    }

    list(path = path, object = obj)
  }

  save_current_result <- function(current_name, plot_fun = NULL, prefix = "result") {
    tryCatch({
      if (is.null(current_name) || !nzchar(current_name)) stop("No result has been generated for this tab yet.", call. = FALSE)
      obj <- rv$objects[[current_name]]
      if (is.null(obj)) stop("Stored result was not found.", call. = FALSE)
      resdir <- ensure_results_dir()
      stamp <- format(Sys.time(), "%Y%m%d_%H%M%S")
      base <- nice_name(paste(prefix, current_name, stamp, sep = "_"))

      base <- basename(tempfile(pattern = paste0(base, "_"), tmpdir = resdir))
      rds_path <- file.path(resdir, paste0(base, ".rds"))
      saveRDS(obj, rds_path)
      rv$files_version <- rv$files_version + 1L
      log_msg(paste("Saved reusable RDS:", rds_path))

      tabs <- all_tables(obj)
      if (length(tabs)) {
        for (tn in names(tabs)) {
          safe_write_csv(tabs[[tn]], file.path(resdir, paste0(base, "__", nice_name(tn), ".csv")))
        }
        if (requireNamespace("writexl", quietly = TRUE)) {
          xlsx_names <- substr(vapply(names(tabs), nice_name, character(1)), 1, 31)
          names(tabs) <- make.unique(xlsx_names, sep = "_")
          writexl::write_xlsx(tabs, path = file.path(resdir, paste0(base, "__tables.xlsx")))
        }
      }

      if (!is.null(plot_fun)) {
        try(save_plot(plot_fun, file.path(resdir, paste0(base, "__plot.png"))), silent = TRUE)
      }
      log_msg(paste("Saved result files to", resdir), "message")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  }

  add_object <- function(name, obj) {
    nm0 <- nice_name(name)
    nm <- nm0
    objs <- rv$objects
    i <- 1L
    while (nm %in% names(objs)) {
      nm <- paste0(nm0, "_", i)
      i <- i + 1L
    }
    objs[[nm]] <- obj
    rv$objects <- objs
    log_msg(paste("Stored result:", nm), "message")
    nm
  }

  selected_source <- function(id) {
    if (is.null(id) || id == "__none__") stop("Please select an input data source.", call. = FALSE)
    if (id == "__dm__") {
      if (is.null(rv$dm)) stop("Please upload dendrometer data first.", call. = FALSE)
      return(rv$dm)
    }
    obj <- rv$objects[[id]]
    if (is.null(obj)) stop("Selected object was not found.", call. = FALSE)
    tab <- first_table(obj)
    if (is.null(tab)) stop("Selected object does not contain a data-frame-like table.", call. = FALSE)
    tab
  }

  selected_object <- function(id) {
    if (is.null(id) || id == "__none__") stop("Please select an input object.", call. = FALSE)
    if (id == "__dm__") {
      if (is.null(rv$dm)) stop("Please upload dendrometer data first.", call. = FALSE)
      return(rv$dm)
    }
    obj <- rv$objects[[id]]
    if (is.null(obj)) stop("Selected object was not found.", call. = FALSE)
    obj
  }

  data_source_choices <- reactive({
    choices <- c()
    if (!is.null(rv$dm)) choices <- c(choices, "Uploaded dendrometer" = "__dm__")
    for (nm in names(rv$objects)) {
      if (!is.null(first_table(rv$objects[[nm]]))) choices <- c(choices, setNames(nm, nm))
    }
    if (!length(choices)) choices <- c("Upload dendrometer data first" = "__none__")
    choices
  })

  object_choices <- reactive({
    choices <- names(rv$objects)
    if (!length(choices)) choices <- character(0)
    choices
  })

  growth_object_choices <- reactive({
    nms <- names(rv$objects)
    keep <- vapply(nms, function(nm) inherits(rv$objects[[nm]], "dm_growth_fit"), logical(1))
    nms[keep]
  })

  clim_twd_object_choices <- reactive({
    nms <- names(rv$objects)
    keep <- vapply(nms, function(nm) inherits(rv$objects[[nm]], "clim_twd_output"), logical(1))
    nms[keep]
  })

  wave_object_choices <- reactive({
    nms <- names(rv$objects)
    keep <- vapply(nms, function(nm) inherits(rv$objects[[nm]], "dm_wavelet") || inherits(rv$objects[[nm]], "dm_wavelet_reconstruct") || inherits(rv$objects[[nm]], "dm_wavelet_coherence"), logical(1))
    nms[keep]
  })

  observe({
    ds <- data_source_choices()
    for (id in c("pre_source", "daily_source", "phase_source", "growth_source", "climtwd_source", "movcor_source", "wave_source")) {
      updateSelectInput(session, id, choices = ds)
    }
    objs <- object_choices()
    updateSelectInput(session, "adv_source", choices = c("Dendrometer data *" = "__dm__", stats::setNames(objs, objs)))
    for (id in c("result_object", "clim_attach_object", "climate_plot_object")) {
      updateSelectInput(session, id, choices = objs)
    }
    updateSelectInput(session, "detrend_object", choices = growth_object_choices())
    updateSelectInput(session, "climtwd_stats_object", choices = clim_twd_object_choices())
    updateSelectInput(session, "wave_object", choices = wave_object_choices())
  })

  output$activity_log <- renderText({
    if (!length(rv$log)) return("No actions yet.")
    paste(rv$log, collapse = "\n")
  })


  observeEvent(input$reload_result, {
    tryCatch({
      z <- load_working_file(input$saved_result, "dm")
      nm <- add_object(tools::file_path_sans_ext(basename(z$path)), z$object)
      updateSelectInput(session, "result_object", selected = nm)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$save_result, { save_current_result(input$result_object) })

  observeEvent(input$load_wd_dm, {
    tryCatch({
      z <- load_working_file(input$wd_dm_file, "dm")
      rv$dm <- z$object
      add_object(paste0("wd_dendrometer_", tools::file_path_sans_ext(basename(z$path))), rv$dm)
      log_msg(paste("Dendrometer data loaded from", basename(z$path)))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$load_wd_clim, {
    tryCatch({
      z <- load_working_file(input$wd_clim_file, "clim")
      rv$clim <- z$object
      add_object(paste0("wd_climate_", tools::file_path_sans_ext(basename(z$path))), rv$clim)
      log_msg(paste("Climate data loaded from", basename(z$path)))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$load_wd_ref, {
    tryCatch({
      z <- load_working_file(input$wd_ref_file, "ref")
      rv$ref <- z$object
      add_object(paste0("wd_reference_", tools::file_path_sans_ext(basename(z$path))), rv$ref)
      log_msg(paste("Reference network loaded from", basename(z$path)))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$load_wd_meta, {
    tryCatch({
      z <- load_working_file(input$wd_meta_file, "meta")
      rv$meta <- z$object
      add_object(paste0("wd_metadata_", tools::file_path_sans_ext(basename(z$path))), rv$meta)
      log_msg(paste("Metadata loaded from", basename(z$path)))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$read_dm, {
    req(input$dm_file)
    tryCatch({
      rv$dm <- call_read_dendrometer(
        file = input$dm_file$datapath,
        sep = input$dm_sep,
        dec = input$dm_dec,
        datetime_col = input$dm_datetime_col,
        date_col = input$dm_date_col,
        time_col = input$dm_time_col,
        tz = input$dm_tz,
        sheet = input$dm_sheet,
        range = input$dm_range
      )
      add_object("uploaded_dendrometer", rv$dm)
      log_msg("Dendrometer data loaded.")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$read_clim, {
    req(input$clim_file)
    tryCatch({
      rv$clim <- call_read_climate(
        file = input$clim_file$datapath,
        sep = input$clim_sep,
        dec = input$clim_dec,
        time_col = input$clim_time_col,
        vars = input$clim_vars,
        tz = input$clim_tz,
        sheet = input$clim_sheet
      )
      add_object("uploaded_climate", rv$clim)
      log_msg("Climate data loaded.")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$read_ref, {
    req(input$ref_file)
    tryCatch({
      rv$ref <- read_uploaded_table(input$ref_file$datapath)
      add_object("reference_network", rv$ref)
      log_msg("Reference network loaded.")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$read_meta, {
    req(input$meta_file)
    tryCatch({
      rv$meta <- read_uploaded_table(input$meta_file$datapath)
      add_object("tree_metadata", rv$meta)
      log_msg("Tree metadata loaded.")
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$dm_preview <- renderDT({
    req(rv$dm)
    datatable(object_preview(rv$dm), options = list(scrollX = TRUE, pageLength = 8))
  })

  output$clim_preview <- renderDT({
    req(rv$clim)
    datatable(object_preview(rv$clim), options = list(scrollX = TRUE, pageLength = 8))
  })

  observeEvent(input$detect_resolution, {
    tryCatch({
      df <- selected_source(input$pre_source)
      res <- safe_call("reso_dm", list(input_time = df[[1]]), "reso_dm")
      rv$current_pre <- add_object("temporal_resolution_minutes", data.frame(resolution_minutes = res))
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$resolution_txt <- renderText({
    obj <- rv$objects[[rv$current_pre %||% ""]]
    if (is.null(obj) || !is.data.frame(obj) || !"resolution_minutes" %in% names(obj)) return("Resolution not detected yet.")
    paste0("Estimated resolution: ", obj$resolution_minutes[1], " minutes")
  })

  observeEvent(input$run_truncate, {
    tryCatch({
      df <- selected_source(input$pre_source)
      out <- safe_call("dendro.truncate", list(df = df, CalYear = parse_num_vec(input$truncate_year), DOY = parse_num_vec(input$truncate_doy)), "dendro.truncate")
      rv$current_pre <- add_object("truncated_dendrometer", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_resample, {
    tryCatch({
      df <- selected_source(input$pre_source)
      out <- safe_call("dendro.resample", list(df = df, by = input$resample_by, value = input$resample_value, method = input$resample_method), "dendro.resample")
      rv$current_pre <- add_object(paste0("resampled_", input$resample_by), out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_na, {
    tryCatch({
      df <- selected_source(input$pre_source)
      out <- safe_call("dm.na.interpolation", list(df = df, resolution = input$na_resolution, fill = input$na_fill, method = input$na_method, assess = input$na_assess, verbose = FALSE), "dm.na.interpolation")
      rv$current_pre <- add_object("na_interpolation", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })


  observeEvent(input$run_network, {
    tryCatch({
      df <- selected_source(input$pre_source)
      if (is.null(rv$ref)) stop("Load a reference dendrometer network in 1. Data first.", call. = FALSE)
      lengths <- parse_num_vec(input$network_lengths)
      if (isTRUE(input$network_assess) &&
          (is.null(lengths) || any(lengths < 1 | lengths != floor(lengths)))) {
        stop("Assessment gap lengths must be positive whole numbers.", call. = FALSE)
      }
      if (is.null(input$network_boot) || !is.finite(input$network_boot) ||
          input$network_boot < 1 || input$network_boot != floor(input$network_boot)) {
        stop("Bootstrap samples must be a positive whole number.", call. = FALSE)
      }
      out <- withProgress(message = "Interpolating with reference network", value = 0.5, {
        safe_call("network.interpolation", list(
          df = df, referenceDF = rv$ref, niMethod = input$network_method,
          n_boot = input$network_boot, return_flags = TRUE,
          return_pi = input$network_pi, assess = input$network_assess,
          assess_lengths_steps = lengths %||% c(1, 2, 4), progress = FALSE,
          correct_gap_jumps = input$network_correct_jumps,
          jump_threshold = input$network_jump_threshold
        ))
      })
      rv$current_pre <- add_object("network_interpolation", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_jump_adjust, {
    tryCatch({
      df <- selected_source(input$pre_source)
      out <- safe_call("jump.locator", list(
        df = df,
        detection_method = input$jump_detection,
        manual_threshold = if (identical(input$jump_detection, "manual")) input$jump_threshold else NULL,
        auto_method_penalty = input$jump_penalty,
        adjustment_method = input$jump_adjustment,
        adjust_window = input$jump_window,
        auto_every_second = input$jump_every_second,
        set_jump_point_na = input$jump_set_na
      ), "jump.locator")
      rv$current_pre <- add_object("jump_adjusted_dendrometer", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_standardize, {
    tryCatch({
      df <- selected_source(input$pre_source)
      out <- safe_call("dm_standardize", list(df = df, season_type = input$std_season, CS_doys = parse_num_vec(input$std_cs), method = input$std_method), "dm_standardize")
      rv$current_pre <- add_object("standardized_dendrometer", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$pre_table <- renderDT({
    req(input$show_pre_data > 0)
    req(rv$current_pre)
    datatable(object_preview(rv$objects[[rv$current_pre]]), options = list(scrollX = TRUE, pageLength = 10))
  })

  make_pre_plot <- reactive({
    req(rv$current_pre)
    obj <- rv$objects[[rv$current_pre]]
    force(obj)
    function() {
      if (inherits(obj, c("dm_na_interpolation", "dm_standardized", "network_interpolation"))) return(plot(obj))
      tab <- first_table(obj)
      validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic plot available for this result."))
      ggplot2::ggplot(as.data.frame(tab), ggplot2::aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) +
        ggplot2::geom_line() + ggplot2::theme_minimal() + ggplot2::labs(x = names(tab)[1], y = names(tab)[2])
    }
  })
  output$pre_plot <- renderPlot({ req(input$show_pre_plot > 0); draw_plot(make_pre_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_pre_plot <- downloadHandler(
    filename = function() paste0("preprocess_plot_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_pre_plot(), file)
  )
  observeEvent(input$save_pre_tab, { save_current_result(rv$current_pre, make_pre_plot(), "preprocess") })

  observeEvent(input$run_daily, {
    tryCatch({
      df <- selected_source(input$daily_source)
      out <- safe_call("daily.data", list(df = df, TreeNum = parse_tree(input$daily_tree)), "daily.data")
      rv$current_daily <- add_object("daily_statistics", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$daily_table <- renderDT({
    req(input$show_daily_data > 0)
    req(rv$current_daily)
    datatable(object_preview(rv$objects[[rv$current_daily]]), options = list(scrollX = TRUE, pageLength = 12))
  })

  make_daily_plot <- reactive({
    req(rv$current_daily)
    obj <- rv$objects[[rv$current_daily]]
    function() {
      plot(
        obj,
        type = input$daily_plot_type,
        stat = input$daily_stat,
        by = input$daily_by,
        box_style = input$daily_box_style,
        Year = parse_num_vec(input$daily_year),
        DOY = parse_num_vec(input$daily_doy)
      )
    }
  })
  output$daily_plot <- renderPlot({ req(input$show_daily_plot > 0); draw_plot(make_daily_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_daily_plot <- downloadHandler(
    filename = function() paste0("daily_plot_", input$daily_plot_type, "_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_daily_plot(), file)
  )
  observeEvent(input$save_daily_tab, { save_current_result(rv$current_daily, make_daily_plot(), "daily") })

  observeEvent(input$run_phase, {
    tryCatch({
      df <- selected_source(input$phase_source)
      smooth <- if (is.null(input$phase_smoothing) || input$phase_smoothing <= 0) NULL else input$phase_smoothing
      if (identical(input$phase_method, "zg")) {
        out <- safe_call("phase.zg", list(df = df, TreeNum = parse_tree(input$phase_tree), smoothing = smooth, beta = input$phase_beta), "phase.zg")
        nm <- "phase_zg"
      } else {
        out <- safe_call("phase.sc", list(df = df, TreeNum = parse_tree(input$phase_tree), smoothing = smooth), "phase.sc")
        nm <- "phase_sc"
      }
      rv$current_phase <- add_object(nm, out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_twd_maxima, {
    tryCatch({
      df <- selected_source(input$phase_source)
      out <- safe_call("twd.maxima", list(df = df, TreeNum = parse_tree(input$phase_tree), smoothing = input$twd_smoothing), "twd.maxima")
      rv$current_phase <- add_object("twd_maxima", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$phase_table <- renderDT({
    req(input$show_phase_data > 0)
    req(rv$current_phase)
    datatable(object_preview(rv$objects[[rv$current_phase]]), options = list(scrollX = TRUE, pageLength = 12))
  })


  phase_plot_kind <- reactive({
    obj <- rv$objects[[rv$current_phase %||% ""]]
    if (inherits(obj, "ZG_output")) "ZG_output" else if (inherits(obj, "SC_output")) "SC_output" else "table"
  })
  output$phase_plot_controls <- renderUI({
    kind <- phase_plot_kind()
    if (kind == "table") return(helpText("Run phase analysis to select its plot options."))
    types <- eval(formals(pkg_fun(paste0("plot.", kind)))$type)
    tagList(
      helpText(paste("Plotting stored result:", rv$current_phase)),
      selectInput("phase_plot_type", "Plot type *", choices = types),
      if (kind == "ZG_output") tagList(
        conditionalPanel("['gro_twd','twd','phase_ribbon','boxplot'].includes(input.phase_plot_type)",
          selectInput("phase_temporal", "Temporal scale", choices = c("raw", "daily", "weekly", "monthly", "yearly"))),
        conditionalPanel("['abr','slope_diagnostics','event_timeline','boxplot'].includes(input.phase_plot_type)",
          selectInput("phase_metric", "Event metric *", choices = {
            cycle <- rv$objects[[rv$current_phase]]$ZG_cycle
            names(cycle)[vapply(cycle, is.numeric, logical(1))]
          }, selected = "ABr.value")),
        conditionalPanel("input.phase_plot_type === 'scatter'",
          selectInput("phase_scatter_source", "Source table *", c("ZG_cycle", "ZG_drought")),
          uiOutput("phase_scatter_variables"),
          selectInput("phase_scatter_fit", "Fit relationship", c("None" = "none", "Linear" = "linear")))
      ),
      if (kind == "SC_output") conditionalPanel("input.phase_plot_type === 'boxplot'",
        selectInput("phase_stat", "Cycle statistic *", choices = {
          cycle <- rv$objects[[rv$current_phase]]$SC_cycle
          names(cycle)[vapply(cycle,is.numeric,logical(1))]
        }, selected = "Magnitude"),
        selectInput("phase_temporal", "Calendar grouping", choices=c("monthly","daily","weekly","yearly","raw"))),
      conditionalPanel("['points','both','shrinkage','expansion','increment','slope_diagnostics','scatter'].includes(input.phase_plot_type)",
        numericInput("phase_point_size", "Point size", 2.5, min = 0.1, max = 10, step = 0.1)),
      conditionalPanel("['ribbon','both','phase_ribbon'].includes(input.phase_plot_type)",
        sliderInput("phase_ribbon_alpha", "Ribbon opacity", min = 0, max = 1, value = 0.35))
    )
  })
  output$phase_scatter_variables <- renderUI({
    req(rv$current_phase)
    dat <- rv$objects[[rv$current_phase]][[input$phase_scatter_source %||% "ZG_cycle"]]
    if (!is.data.frame(dat)) return(helpText("This result has no selected table; run phase analysis again."))
    choices <- names(dat)[vapply(dat, is.numeric, logical(1))]
    tagList(selectInput("phase_scatter_x", "X parameter *", choices, selected = choices[1]),
            selectInput("phase_scatter_y", "Y parameter *", choices, selected = if ("ABr.value" %in% choices) "ABr.value" else choices[2]))
  })
  make_phase_plot <- reactive({
    req(rv$current_phase)
    obj <- rv$objects[[rv$current_phase]]
    kind <- phase_plot_kind()
    function() {
      if (kind != "table") {
        fun <- pkg_fun(paste0("plot.", kind))
        types <- eval(formals(fun)$type)
        type <- input$phase_plot_type %||% types[1]
        validate(need(type %in% types, "Select a plot type for the current result."))
        args <- list(x = obj, type = type, Year = parse_num_vec(input$phase_year),
                     DOY = parse_num_vec(input$phase_doy),
                     point_size = input$phase_point_size %||% 2.5,
                     ribbon_alpha = input$phase_ribbon_alpha %||% 0.35)
        if (kind == "SC_output" && type == "boxplot") {
          args$stat <- input$phase_stat %||% "Magnitude"
          args$temporal <- input$phase_temporal %||% "monthly"
        }
        if (kind == "ZG_output") {
          args$temporal <- if (type %in% c("gro_twd", "twd", "phase_ribbon", "boxplot")) input$phase_temporal %||% "raw" else "raw"
          args$metric <- input$phase_metric %||% "ABr.value"
          if (type == "scatter") {
            req(input$phase_scatter_x, input$phase_scatter_y)
            args$source <- input$phase_scatter_source %||% "ZG_cycle"
            args$xvar <- input$phase_scatter_x
            args$yvar <- input$phase_scatter_y
            args$fit <- input$phase_scatter_fit %||% "none"
          }
        }
        do.call(fun, args)
      } else {
        tab <- first_table(obj)
        validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic plot available."))
        ggplot(tab, aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) + geom_line() + theme_minimal()
      }
    }
  })
  output$phase_plot <- renderPlot({ req(input$show_phase_plot > 0); draw_plot(make_phase_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$phase_scatter_stats <- renderTable({
    req(input$show_phase_plot > 0, input$phase_plot_type == "scatter", phase_plot_kind() == "ZG_output")
    attr(make_phase_plot()(), "scatter_stats")
  }, digits = 5)
  output$download_phase_plot <- downloadHandler(
    filename = function() paste0("phase_plot_", input$phase_plot_type, "_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_phase_plot(), file)
  )
  observeEvent(input$save_phase_tab, { save_current_result(rv$current_phase, make_phase_plot(), "phase") })

  observeEvent(input$run_growth, {
    tryCatch({
      df <- selected_source(input$growth_source)
      common <- list(
        df = df,
        TreeNum = parse_tree(input$growth_tree),
        years = parse_chr_vec(input$growth_years, default = "all"),
        year_mode = input$growth_year_mode,
        fit_GRO = input$growth_fit_gro,
        site_mode = input$growth_site_mode,
        custom_veg_season = parse_num_vec(input$growth_custom_season, default = c(275, 274)),
        growth_fraction = parse_num_vec(input$growth_fraction, default = c(0.1, 0.9)),
        min_unique_growth = input$growth_min_unique,
        rate_threshold_fraction = input$growth_rate_threshold,
        verbose = TRUE
      )
      if (identical(input$growth_engine, "single")) {
        args <- c(common, list(method = input$growth_method, fix_a_to_observed_max = input$growth_fix_a))
        out <- safe_call("dm.growth.fit", args, "dm.growth.fit")
        nm <- paste0("growth_fit_", input$growth_method)
      } else if (identical(input$growth_engine, "double")) {
        args <- c(common, list(method = input$growth_double_method, fix_a_to_observed_max = input$growth_fix_a))
        out <- safe_call("dm.growth.fit.double", args, "dm.growth.fit.double")
        nm <- paste0("growth_fit_double_", input$growth_double_method)
      } else {
        args <- c(common, list(methods = input$growth_eval_methods))
        out <- safe_call("dm.growth.evaluate", args, "dm.growth.evaluate")
        nm <- "growth_method_evaluation"
      }
      rv$current_growth <- add_object(nm, out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_detrend, {
    tryCatch({
      req(input$detrend_object)
      obj <- rv$objects[[input$detrend_object]]
      out <- safe_call("dm.detrend.fit", list(x = obj), "dm.detrend.fit")
      rv$current_growth <- add_object("detrended_growth_fit", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_mean_detrended, {
    tryCatch({
      req(rv$current_growth)
      obj <- rv$objects[[rv$current_growth]]
      out <- safe_call("mean_detrended.dm", list(detrended_dm = obj), "mean_detrended.dm")
      rv$current_growth <- add_object("mean_detrended_series", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$growth_table <- renderDT({
    req(input$show_growth_data > 0)
    req(rv$current_growth)
    datatable(object_preview(rv$objects[[rv$current_growth]]), options = list(scrollX = TRUE, pageLength = 12))
  })


  observe({
    obj <- rv$objects[[rv$current_growth %||% ""]]
    choices <- if (inherits(obj,"dm_growth_evaluation")) c("boxplot","mean","heatmap") else if (inherits(obj,"dm_detrended")) c("compare","boxplot") else if (inherits(obj,"mean_dm_detrended")) "default" else c("fit","season","residuals","timing","overlay","parameters")
    updateSelectInput(session,"growth_plot_type",choices=choices)
  })
  observe({
    name <- input$wave_object
    if (is.null(name) || !nzchar(name)) name <- rv$current_wave
    obj <- rv$objects[[name %||% ""]]
    choices <- if (inherits(obj,"dm_wavelet_coherence")) c("coherence","cross_power","average_coherence","average_cross_power","phase","series") else if (inherits(obj,"dm_wavelet_reconstruct")) "compare" else c("power","average","series")
    updateSelectInput(session,"wave_plot_type",choices=choices)
  })

  make_growth_plot <- reactive({
    req(rv$current_growth)
    obj <- rv$objects[[rv$current_growth]]
    function() {
      if (inherits(obj, "dm_growth_evaluation")) {
        eval_type <- if (input$growth_plot_type %in% c("boxplot", "mean", "heatmap")) input$growth_plot_type else "boxplot"
        plot(obj, metric = input$growth_metric, type = eval_type)
      } else if (inherits(obj, "dm_growth_fit")) {
        fit_type <- if (input$growth_plot_type %in% c("fit", "season", "residuals", "timing", "overlay", "parameters")) input$growth_plot_type else "fit"
        plot(obj, type = fit_type, x_axis = input$growth_x_axis)
      } else if (inherits(obj, "dm_detrended")) {
        plot(obj, type = if (input$growth_plot_type %in% c("boxplot")) "boxplot" else "compare")
      } else if (inherits(obj, "mean_dm_detrended")) {
        plot(obj)
      } else {
        tab <- first_table(obj)
        validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic growth plot available for this result."))
        ggplot2::ggplot(as.data.frame(tab), ggplot2::aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) +
          ggplot2::geom_line() + ggplot2::theme_minimal()
      }
    }
  })
  output$growth_plot <- renderPlot({ req(input$show_growth_plot > 0); draw_plot(make_growth_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_growth_plot <- downloadHandler(
    filename = function() paste0("growth_plot_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_growth_plot(), file)
  )
  observeEvent(input$save_growth_tab, { save_current_result(rv$current_growth, make_growth_plot(), "growth") })

  observeEvent(input$run_daily_clim, {
    tryCatch({
      req(rv$clim)
      out <- safe_call("dm_daily_clim", list(
        clim_df = rv$clim,
        mean_vars = parse_chr_vec(input$daily_mean_vars, NULL),
        min_vars = parse_chr_vec(input$daily_min_vars, NULL),
        max_vars = parse_chr_vec(input$daily_max_vars, NULL),
        sum_vars = parse_chr_vec(input$daily_sum_vars, NULL),
        median_vars = parse_chr_vec(input$daily_median_vars, NULL)
      ), "dm_daily_clim")
      rv$current_climate <- add_object("daily_climate", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_subdaily_clim, {
    tryCatch({
      req(rv$clim)
      out <- safe_call("dm_subdaily_clim", list(
        clim_df = rv$clim,
        mean_vars = parse_chr_vec(input$sub_mean_vars, NULL),
        sum_vars = parse_chr_vec(input$sub_sum_vars, NULL),
        lag_vars = parse_chr_vec(input$sub_lag_vars, NULL),
        roll_hours = parse_num_vec(input$sub_roll_hours, c(3, 6, 24))
      ), "dm_subdaily_clim")
      rv$current_climate <- add_object("subdaily_climate", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_add_climate, {
    tryCatch({
      req(input$clim_attach_object, rv$clim)
      obj <- rv$objects[[input$clim_attach_object]]
      out <- safe_call("dm_add_climate", list(
        x = obj,
        clim = rv$clim,
        scale = input$clim_attach_scale,
        mean_vars = parse_chr_vec(input$daily_mean_vars, NULL),
        min_vars = parse_chr_vec(input$daily_min_vars, NULL),
        max_vars = parse_chr_vec(input$daily_max_vars, NULL),
        sum_vars = parse_chr_vec(input$daily_sum_vars, NULL),
        median_vars = parse_chr_vec(input$daily_median_vars, NULL),
        sub_mean_vars = parse_chr_vec(input$sub_mean_vars, NULL),
        sub_sum_vars = parse_chr_vec(input$sub_sum_vars, NULL),
        sub_lag_vars = parse_chr_vec(input$sub_lag_vars, NULL),
        roll_hours = parse_num_vec(input$sub_roll_hours, c(3, 6, 24))
      ), "dm_add_climate")
      rv$current_climate <- add_object("climate_attached", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_clim_twd, {
    tryCatch({
      if (is.null(rv$clim)) stop("Please load climate data first.", call. = FALSE)
      df <- selected_source(input$climtwd_source)
      # clim.twd() needs daily climate with a date column and one variable
      clim <- as.data.frame(rv$clim)
      num_vars <- names(clim)[-1][vapply(clim[-1], is.numeric, logical(1))]
      if (!length(num_vars)) stop("The climate data contain no numeric variable.", call. = FALSE)
      var <- null_if_blank(input$climtwd_var) %||% num_vars[1]
      if (!var %in% num_vars) stop("Climate variable '", var, "' was not found. Available: ", paste(num_vars, collapse = ", "), call. = FALSE)
      tm <- clim[[1]]
      if (!inherits(tm, c("Date", "POSIXct"))) tm <- lubridate::parse_date_time(tm, c("Ymd HMS", "Ymd HM", "Ymd"))
      date <- if (inherits(tm, "POSIXct")) as.Date(format(tm, "%Y-%m-%d")) else as.Date(tm)
      fun <- match.fun(input$climtwd_clim_fun %||% "sum")
      clim_day <- stats::aggregate(list(value = clim[[var]]), by = list(DATE = date),
                                   FUN = function(v) if (all(is.na(v))) NA_real_ else fun(v, na.rm = TRUE))
      names(clim_day)[2] <- var
      out <- safe_call("clim.twd", list(
        df = df,
        Clim = clim_day,
        dailyValue = input$climtwd_daily_value,
        thresholdClim = input$climtwd_threshold_clim,
        thresholdDays = input$climtwd_threshold_days,
        showPlot = FALSE
      ), "clim.twd")
      rv$current_climate <- add_object("climate_twd", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_climtwd_stats, {
    tryCatch({
      req(input$climtwd_stats_object)
      obj <- rv$objects[[input$climtwd_stats_object]]
      out <- safe_call("clim.twd.stats", list(x = obj, tree_info = rv$meta, group_by = input$climtwd_group), "clim.twd.stats")
      rv$current_climate <- add_object("climate_twd_stats", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_movcor, {
    tryCatch({
      req(rv$clim)
      df <- selected_source(input$movcor_source)
      out <- safe_call("mov.cor.dm", list(
        df = df,
        Clim = rv$clim,
        TreeNum = parse_int(input$movcor_tree, 1),
        win_size = input$movcor_win,
        cor_method = input$movcor_method,
        clim_vars = parse_chr_vec(input$movcor_vars, NULL)
      ), "mov.cor.dm")
      rv$current_climate <- add_object("moving_correlation", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$climate_table <- renderDT({
    req(input$show_climate_data > 0)
    req(rv$current_climate)
    datatable(object_preview(rv$objects[[rv$current_climate]]), options = list(scrollX = TRUE, pageLength = 12))
  })

  # Climate variables attached to a daily, ZG or SC climate object
  climate_var_choices <- function(obj) {
    ns <- asNamespace(app_pkg)
    if (inherits(obj, "daily_output_clim")) {
      dat <- as.data.frame(obj)
      num <- names(dat)[vapply(dat, is.numeric, logical(1))]
      raw <- if (!is.null(rv$clim)) names(rv$clim)[-1] else character()
      hit <- num[vapply(num, function(n) any(n == raw | startsWith(n, paste0(raw, "_"))), logical(1))]
      return(if (length(hit)) hit else num)
    }
    kind <- if (inherits(obj, "ZG_output_clim")) "ZG" else "SC"
    cyc <- as.data.frame(obj[[paste0(kind, "_cycle")]])
    ph <- as.data.frame(obj[[paste0(kind, "_phase")]])
    unique(c(ns$.dm_detect_cycle_climate_cols(cyc, kind = kind), ns$.dm_detect_point_climate_cols(ph, kind = kind)))
  }

  observe({
    obj <- rv$objects[[input$climate_plot_object %||% ""]]
    if (!inherits(obj, c("daily_output_clim", "ZG_output_clim", "SC_output_clim"))) return()
    ch <- tryCatch(climate_var_choices(obj), error = function(e) character())
    if (length(ch)) updateTextInput(session, "climate_plot_var", placeholder = paste("blank =", ch[1], "| available:", paste(head(ch, 6), collapse = ", ")))
  })

  make_climate_plot <- reactive({
    input$refresh_climate_plot
    obj_name <- input$climate_plot_object %||% rv$current_climate
    req(obj_name)
    obj <- rv$objects[[obj_name]]
    function() {
      if (inherits(obj, c("daily_output_clim", "ZG_output_clim", "SC_output_clim"))) {
        if(input$climate_plot_type %in% c("cor_heatmap","regression_heatmap")) return(pkg_fun("dm_plot_climate_compare")(x=obj,type=input$climate_plot_type))
        choices <- climate_var_choices(obj)
        validate(need(length(choices) > 0, "No attached climate variables were found in this object."))
        cvar <- null_if_blank(input$climate_plot_var) %||% choices[1]
        validate(need(cvar %in% choices, paste0("Climate variable '", cvar, "' was not found. Available: ", paste(choices, collapse = ", "))))
        pkg_fun("dm_plot_climate")(
          x = obj,
          climate_var = cvar,
          metric_var = null_if_blank(input$climate_metric_var),
          type = input$climate_plot_type
        )
      } else if (inherits(obj, "mov_cor_dm")) {
        plot(obj)
      } else if (inherits(obj, "clim_twd_stats")) {
        plot(obj)
      } else if (inherits(obj, "clim_twd_test")) {
        plot(obj)
      } else {
        tab <- first_table(obj)
        validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic climate plot available for this result."))
        ggplot2::ggplot(as.data.frame(tab), ggplot2::aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) +
          ggplot2::geom_line() + ggplot2::theme_minimal()
      }
    }
  })
  output$climate_plot <- renderPlot({ req(input$show_climate_plot > 0); draw_plot(make_climate_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_climate_plot <- downloadHandler(
    filename = function() paste0("climate_plot_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_climate_plot(), file)
  )
  observeEvent(input$save_climate_tab, { save_current_result(rv$current_climate, make_climate_plot(), "climate") })

  observeEvent(input$run_wavelet, {
    tryCatch({
      obj <- selected_object(input$wave_source)
      out <- safe_call("dm_wavelet", list(
        x = obj,
        TreeNum = parse_tree(input$wave_tree),
        source = input$wave_input_source,
        na_action = input$wave_na,
        loess_span = input$wave_loess,
        dj = input$wave_dj,
        lowerPeriod = parse_num_vec(input$wave_lower, NULL),
        upperPeriod = parse_num_vec(input$wave_upper, NULL),
        make_pval = input$wave_pval,
        n_sim = input$wave_nsim,
        verbose = TRUE
      ), "dm_wavelet")
      rv$current_wave <- add_object("wavelet", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_reconstruct, {
    tryCatch({
      req(input$wave_object)
      obj <- rv$objects[[input$wave_object]]
      lower <- parse_num_vec(input$recon_lower, NULL)
      upper <- parse_num_vec(input$recon_upper, NULL)
      use_band <- !is.null(lower) && !is.null(upper)
      out <- safe_call("dm_wavelet_reconstruct", list(
        x = obj,
        series = parse_chr_vec(input$recon_series, NULL),
        mode = input$recon_mode,
        period_hours = if (use_band) NULL else parse_num_vec(input$recon_periods, NULL),
        lower_hours = if (use_band) lower else NULL,
        upper_hours = if (use_band) upper else NULL
      ), "dm_wavelet_reconstruct")
      rv$current_wave <- add_object("wavelet_reconstruction", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  observeEvent(input$run_coherence, {
    tryCatch({
      req(rv$clim)
      dm_obj <- selected_object(input$wave_source)
      out <- safe_call("dm_wavelet_coherence", list(
        dm = dm_obj,
        clim = rv$clim,
        TreeNum = parse_tree(input$wave_tree),
        clim_vars = parse_chr_vec(input$coh_clim_vars, default = "all"),
        source = input$wave_input_source,
        na_action = input$wave_na,
        loess_span = input$wave_loess,
        dj = input$wave_dj,
        make_pval = input$wave_pval,
        n_sim = input$wave_nsim,
        verbose = TRUE
      ), "dm_wavelet_coherence")
      rv$current_wave <- add_object("wavelet_coherence", out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$wave_table <- renderDT({
    req(input$show_wave_data > 0)
    req(rv$current_wave)
    datatable(object_preview(rv$objects[[rv$current_wave]]), options = list(scrollX = TRUE, pageLength = 12))
  })

  make_wave_plot <- reactive({
    input$refresh_wave_plot
    obj_name <- input$wave_object %||% rv$current_wave
    if (is.null(obj_name) || !nzchar(obj_name)) obj_name <- rv$current_wave
    req(obj_name)
    obj <- rv$objects[[obj_name]]
    function() {
      if (inherits(obj, "dm_wavelet")) {
        wtype <- if (input$wave_plot_type %in% c("power", "average", "series")) input$wave_plot_type else "power"
        plot(obj, type = wtype)
      } else if (inherits(obj, "dm_wavelet_reconstruct")) {
        plot(obj, type = "compare")
      } else if (inherits(obj, "dm_wavelet_coherence")) {
        ctype <- if (input$wave_plot_type %in% c("coherence", "cross_power", "average_coherence", "average_cross_power", "phase", "series")) input$wave_plot_type else "coherence"
        plot(obj, type = ctype)
      } else {
        tab <- first_table(obj)
        validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic wavelet plot available for this result."))
        ggplot2::ggplot(as.data.frame(tab), ggplot2::aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) +
          ggplot2::geom_line() + ggplot2::theme_minimal()
      }
    }
  })
  output$wave_plot <- renderPlot({ req(input$show_wave_plot > 0); draw_plot(make_wave_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_wave_plot <- downloadHandler(
    filename = function() paste0("wavelet_plot_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_wave_plot(), file)
  )
  observeEvent(input$save_wave_tab, { save_current_result(rv$current_wave, make_wave_plot(), "wavelet") })

  output$adv_required <- renderText({
    req(input$adv_function)
    f <- formals(pkg_fun(input$adv_function))
    required <- names(f)[vapply(f, function(x) identical(x, quote(expr = )), logical(1))]
    paste("Required arguments (*):", paste(setdiff(required, "..."), collapse = ", "), "\nPrimary input and enabled climate/reference inputs are passed automatically when supported.")
  })

  parse_adv_args <- function(txt) {
    txt <- null_if_blank(txt)
    if (is.null(txt)) return(list())
    eval(parse(text = paste0("list(", txt, ")")), envir = parent.frame())
  }

  observeEvent(input$run_advanced, {
    tryCatch({
      fun <- pkg_fun(input$adv_function)
      fml <- names(formals(fun))
      args <- parse_adv_args(input$adv_args)
      primary <- selected_object(input$adv_source)
      for (arg in intersect(c("daily_obj", "event_data"), fml)) {
        if (is.null(args[[arg]])) args[[arg]] <- primary
      }
      if ("events" %in% fml && is.null(args$events)) args$events <- primary
      if (identical(input$adv_function, "smooth_dm")) {
        tab <- first_table(primary)
        if (is.null(args$time)) args$time <- tab[[1]]
        if (is.null(args$dm)) args$dm <- tab[[2]]
      }
      if (identical(input$adv_function, "dm_wavelet_coherence") && is.null(args$dm)) args$dm <- primary
      if ("tree_info" %in% fml && is.null(args$tree_info) && !is.null(rv$meta)) args$tree_info <- rv$meta
      if ("df" %in% fml && is.null(args$df)) args$df <- if (is.data.frame(primary)) primary else first_table(primary)
      if ("x" %in% fml && is.null(args$x)) args$x <- primary
      if ("detrended_dm" %in% fml && is.null(args$detrended_dm)) args$detrended_dm <- primary
      if ("input_time" %in% fml && is.null(args$input_time)) args$input_time <- first_table(primary)[[1]]
      if (isTRUE(input$adv_use_climate) && !is.null(rv$clim)) {
        if ("Clim" %in% fml && is.null(args$Clim)) args$Clim <- rv$clim
        if ("clim" %in% fml && is.null(args$clim)) args$clim <- rv$clim
        if ("clim_df" %in% fml && is.null(args$clim_df)) args$clim_df <- rv$clim
        if ("clim_daily" %in% fml && is.null(args$clim_daily)) args$clim_daily <- rv$clim
        if ("clim_sub" %in% fml && is.null(args$clim_sub)) args$clim_sub <- rv$clim
      }
      if (isTRUE(input$adv_use_reference) && !is.null(rv$ref) && "referenceDF" %in% fml && is.null(args$referenceDF)) args$referenceDF <- rv$ref
      out <- do.call(fun, args)
      rv$current_advanced <- add_object(paste0("advanced_", input$adv_function), out)
    }, error = function(e) log_msg(conditionMessage(e), "error"))
  })

  output$advanced_table <- renderDT({
    req(input$show_advanced_data > 0)
    req(rv$current_advanced)
    datatable(object_preview(rv$objects[[rv$current_advanced]]), options = list(scrollX = TRUE, pageLength = 12))
  })

  make_advanced_plot <- reactive({
    req(rv$current_advanced)
    obj <- rv$objects[[rv$current_advanced]]
    function() {
      tryCatch(plot(obj), error = function(e) {
        tab <- first_table(obj)
        validate(need(!is.null(tab) && ncol(tab) >= 2, "No automatic plot method available for this advanced result."))
        ggplot2::ggplot(as.data.frame(tab), ggplot2::aes(x = .data[[names(tab)[1]]], y = .data[[names(tab)[2]]])) +
          ggplot2::geom_line() + ggplot2::theme_minimal()
      })
    }
  })
  output$advanced_plot <- renderPlot({ req(input$show_advanced_plot > 0); draw_plot(make_advanced_plot()) }, width = function() plot_settings()[["width"]] * 96, height = function() plot_settings()[["height"]] * 96, res = 96)
  output$download_advanced_plot <- downloadHandler(
    filename = function() paste0("advanced_plot_", Sys.Date(), ".png"),
    content = function(file) save_plot(make_advanced_plot(), file)
  )
  observeEvent(input$save_advanced_tab, { save_current_result(rv$current_advanced, make_advanced_plot(), "advanced") })

  output$object_summary <- renderPrint({
    req(input$result_object)
    str(rv$objects[[input$result_object]], max.level = 2, give.attr = FALSE)
  })

  output$result_table <- renderDT({
    req(input$result_object)
    datatable(object_preview(rv$objects[[input$result_object]]), options = list(scrollX = TRUE, pageLength = 15))
  })

  output$download_rds <- downloadHandler(
    filename = function() paste0(input$result_object %||% "result", ".rds"),
    content = function(file) {
      req(input$result_object)
      saveRDS(rv$objects[[input$result_object]], file)
    }
  )

  output$download_csv <- downloadHandler(
    filename = function() paste0(input$result_object %||% "result", ".csv"),
    content = function(file) {
      req(input$result_object)
      tab <- first_table(rv$objects[[input$result_object]])
      validate(need(!is.null(tab), "The selected object does not contain a table."))
      safe_write_csv(tab, file)
    }
  )

  output$download_xlsx <- downloadHandler(
    filename = function() paste0(input$result_object %||% "result", "_tables.xlsx"),
    content = function(file) {
      req(input$result_object)
      tabs <- all_tables(rv$objects[[input$result_object]])
      validate(need(length(tabs) > 0, "The selected object does not contain tables."))
      if (!requireNamespace("writexl", quietly = TRUE)) stop("Package 'writexl' is required for XLSX export.")
      names(tabs) <- substr(vapply(names(tabs), nice_name, character(1)), 1, 31)
      writexl::write_xlsx(tabs, path = file)
    }
  )

  output$download_all_zip <- downloadHandler(
    filename = function() paste0("dendroAnalyst_results_", Sys.Date(), ".zip"),
    content = function(file) {
      tmp <- tempfile("dendro_results_")
      dir.create(tmp, recursive = TRUE)
      dir.create(file.path(tmp, "objects"))
      dir.create(file.path(tmp, "tables"))
      for (nm in names(rv$objects)) {
        saveRDS(rv$objects[[nm]], file.path(tmp, "objects", paste0(nice_name(nm), ".rds")))
        tabs <- all_tables(rv$objects[[nm]])
        if (length(tabs)) {
          for (tn in names(tabs)) {
            safe_write_csv(tabs[[tn]], file.path(tmp, "tables", paste0(nice_name(nm), "__", nice_name(tn), ".csv")))
          }
        }
      }
      writeLines(rv$log, file.path(tmp, "activity_log.txt"))
      oldwd <- getwd()
      on.exit(setwd(oldwd), add = TRUE)
      setwd(tmp)
      files <- list.files(".", recursive = TRUE, full.names = FALSE)
      if (requireNamespace("zip", quietly = TRUE)) {
        zip::zipr(zipfile = file, files = files)
      } else {
        utils::zip(zipfile = file, files = files)
      }
    }
  )

  observeEvent(input$clear_results, {
    rv$objects <- list()
    rv$current_pre <- rv$current_daily <- rv$current_phase <- rv$current_growth <- rv$current_climate <- rv$current_wave <- rv$current_advanced <- NULL
    log_msg("Stored results cleared.", "warning")
  })
}

shinyApp(ui, server)

Try the dendRoAnalyst package in your browser

Any scripts or data that you put into this service are public.

dendRoAnalyst documentation built on Oct. 2, 2026, 9:06 a.m.