diff --git a/.Rbuildignore b/.Rbuildignore index f13ae61..5b011ba 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -8,6 +8,8 @@ $run_dev.* ^README\.Rmd$ ^CODE_OF_CONDUCT\.md$ ^scratchpad\.md$ -^app\.R$ ^\.Renviron\.example$ ^\.github$ +^.*\.log$ +^AGENTS\.md$ +^rsconnect$ diff --git a/.Rprofile b/.Rprofile new file mode 100644 index 0000000..41b5b0c --- /dev/null +++ b/.Rprofile @@ -0,0 +1,13 @@ +# Set CRAN repository to Posit snapshot matching the R release date +# This ensures reproducible package installation across different environments +local({ + version_info <- R.Version() + release_date <- sprintf( + "%s-%02i-%02i", + version_info$year, + as.integer(version_info$month), + as.integer(version_info$day) + ) + repos <- sprintf("https://packagemanager.posit.co/cran/%s", release_date) + options(repos = repos) +}) diff --git a/.github/actions/capture-cran-snapshot/action.yml b/.github/actions/capture-cran-snapshot/action.yml index 8460459..995d6da 100644 --- a/.github/actions/capture-cran-snapshot/action.yml +++ b/.github/actions/capture-cran-snapshot/action.yml @@ -14,7 +14,7 @@ runs: as.integer(version_info$month), as.integer(version_info$day) ) - repos <- sprintf("https://packagemanager.rstudio.com/cran/%s", release_date) + repos <- sprintf("https://packagemanager.posit.co/cran/%s", release_date) cat( sprintf("CRAN_SNAPSHOT=%s\n", repos), file = Sys.getenv("GITHUB_ENV"), diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24e511e..346feda 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,13 +5,45 @@ - Always use the native pipe operator `|>` instead of the magrittr pipe `%>%` in all R code. - Use NZ (New Zealand) English spelling for all function names and documentation (e.g., "colour" not "color"). - Name files in `R/` with hyphenated prefixes to signal logical layers (e.g., `domain-value_objects.R`) because subdirectories are not permitted. +- Assume all the required packages are declared in `DESCRIPTION` and installed in the environment; Never not use runtime checks like `if (requireNamespace("pkgname"))` in package scripts. --- +## Shiny Application Design and Development + +- When designing a Shiny web app part, use the context7 tool first to plan the work. +- Search context7 for "golem" to understand how to structure the files so the web app behaves like a package. +- Use context7 for the "shiny" package when you need reference on basic Shiny components. +- Consult context7 for "shinydashboard" when working with dashboard-specific layout parts. +- Consult context7 for "shinydashboardPlus" when handling controlbar or other dashboard-plus specific components. +- Refer to context7 for "mastering-shiny" for guidance on Shiny mechanics, best practices, and reactivity patterns. + +--- + +## Architecture and Design Principles + +- We use a layered architecture approach, separating concerns into distinct layers (e.g., domain, functions, application) to enhance maintainability and testability. +- Each layer should only depend on layers below it, promoting loose coupling and high cohesion. +- Functions that belong to different layers, live in different files. We name our files in `R/` starting with the layer name, and then hyphenated for the module. + +The layers are as such: +- app | mod +- functions +- domain + +For functions that receive and/or return `data.frame` we strive to use value objects to our instead of generic data.frames + ## Linter - Markdown: avoid tab characters and keep blank lines before/after fenced code blocks. - Markdown: start files with a top-level heading. - Markdown: surround lists with blank lines. +- Markdown: ensure fenced code blocks are balanced (no stray closing ```); remove unmatched fences. +- R: replace non-ASCII characters in R source code with ASCII equivalents (e.g., em dash `—` → hyphen `-`). Non-ASCII characters in data/config (YAML, comments) are acceptable. + - R: avoid raw non-ASCII symbols in R/ source (e.g., µ, superscripts). Use plotmath in code (e.g. `expression(mu)` or `annotate(..., label = "mu == 10", parse = TRUE)`) and use `\eqn{\mu}` (with an ASCII fallback) in roxygen/Rd. Non-ASCII in data/config (YAML, comments) is acceptable. +- R: avoid `@importFrom` for functions that conflict with base R (e.g., `config::get()`, `base::get()`); use namespace notation instead. +- R: in plotmath unit expressions use `*` instead of `~` for tight prefix spacing (e.g., `mu*g/cm^2`). +- R: in roxygen2 `@param` and roxygen comments, escape curly braces as `\{` and `\}` (e.g., `\{shiny\}` not `{shiny}`) to prevent Rd formatting errors. +- R: use `.data$column_name` instead of bare column names in `dplyr` verbs to avoid R CMD check global variable binding warnings (e.g., `dplyr::filter(.data$id == "value")`). --- @@ -41,4 +73,40 @@ - **Use ggplot2 for all plots**: All visualizations should be created using the ggplot2 package to ensure consistency and leverage its powerful layering system. -- **Use the `scales` package for axis formatting**: For formatting axis labels and breaks, always use functions from the `scales` package (e.g., `scales::label_number()`, `scales::breaks_extended()`) to ensure consistent and professional appearance. \ No newline at end of file +- **Use the `scales` package for axis formatting**: For formatting axis labels and breaks, always use functions from the `scales` package (e.g., `scales::label_number()`, `scales::breaks_extended()`) to ensure consistent and professional appearance. + +--- + +## Shiny Module & Configuration Best Practices + +- **Externalize configuration to YAML**: Store slider parameters, UI configuration, and other settings in `inst/config/` as YAML files instead of hardcoding in R. Use `config::get()` to load at package initialization. + +- **Use `purrr::map_dfr()` for robust config conversion**: When converting lists from config files to tibbles, prefer `purrr::map_dfr(cfg, tibble::as_tibble)` over `do.call(rbind, lapply(...))` for better type preservation and error handling. + +- **Avoid row iteration with `seq_len(nrow())`**: When iterating over data frame/tibble rows, use `purrr::pmap()` instead of `lapply(seq_len(nrow(...)), ...)` for better performance and readability. + +- **Validate config early**: Add error handling when loading external configuration to fail fast if config is empty, malformed, or missing required fields. + +--- + +## Data Wrangling + +- Prefer `dplyr` (tidyverse) verbs for data manipulation for clearer intent, consistent semantics with tibbles, and better compatibility with grouped operations. + +- Prefer `dplyr` alternatives to base functions. For example: + + - use `dplyr::group_split()` rather than `base::split()` + - use `dplyr::bind_rows()` rather than `base::rbind()` + - use `dplyr::filter(.data$col == value)` rather than `base::subset()` or manual indexing + +- Avoid concatenating multiple columns into a single string to encode structured information, and avoid ad-hoc parsing of strings to reconstruct columns. Keep distinct data elements in separate columns (or use list-columns) so types and semantics are preserved. + +- When splitting, binding, or combining data, prefer the `dplyr`/tidyverse approach to preserve column types and attributes and to avoid unexpected behaviour with tibbles. + +--- + +## Defensive checks guideline + +- Never add defensive checks (for example, input validation, `stop()` on missing columns, or NULL guards) inside function bodies unless explicitly instructed to do so. +- Do not add `warning()` calls inside functions unless explicitly instructed to do so. +- Assume validated value objects provide required columns and types; do not re-check inputs inside functions that use value-objects. \ No newline at end of file diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index fb09a18..222532e 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -2,8 +2,16 @@ # Need help debugging build failures? Start at # https://github.com/r-lib/actions#where-to-find-help on: - push: pull_request: + push: + branches: + - master + workflow_dispatch: + inputs: + ref: + description: 'Git ref (branch, tag, or SHA) to check' + required: false + default: '' name: R-CMD-check @@ -12,6 +20,8 @@ permissions: read-all jobs: R-CMD-check: runs-on: ubuntu-latest + container: + image: rocker/shiny-verse:4.5.1 env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} @@ -37,11 +47,20 @@ jobs: install-r: false cran: "${{ env.CRAN_SNAPSHOT }}" - - uses: r-lib/actions/setup-r-dependencies@v2 + - name: Cache check tooling + uses: r-lib/actions/setup-r-dependencies@v2 with: + packages: | + any::sessioninfo extra-packages: any::roxygen2,any::rcmdcheck,any::desc cache: true cache-version: ubuntu-r-4-5-1 + + - name: Cache package dependencies + uses: r-lib/actions/setup-r-dependencies@v2 + with: + cache: true + cache-version: ubuntu-r-4-5-1 - name: Build documentation with roxygen2 run: roxygen2::roxygenise() diff --git a/.github/workflows/deploy-shinyapps.yaml b/.github/workflows/deploy-shinyapps.yaml index 4639331..8b00b07 100644 --- a/.github/workflows/deploy-shinyapps.yaml +++ b/.github/workflows/deploy-shinyapps.yaml @@ -13,6 +13,8 @@ permissions: read-all jobs: deploy: runs-on: ubuntu-latest + container: + image: rocker/shiny-verse:4.5.1 environment: Production env: @@ -22,11 +24,12 @@ jobs: steps: - name: Verify required secrets env: + SHINYAPPS_APPNAME: ${{ secrets.SHINYAPPS_APPNAME || vars.SHINYAPPS_APPNAME }} SHINYAPPS_NAME: ${{ secrets.SHINYAPPS_NAME || vars.SHINYAPPS_NAME }} SHINYAPPS_TOKEN: ${{ secrets.SHINYAPPS_TOKEN || vars.SHINYAPPS_TOKEN }} SHINYAPPS_SECRET: ${{ secrets.SHINYAPPS_SECRET || vars.SHINYAPPS_SECRET }} run: | - [ -n "$SHINYAPPS_NAME" ] && [ -n "$SHINYAPPS_TOKEN" ] && [ -n "$SHINYAPPS_SECRET" ] || { echo "Error: Missing required secrets (SHINYAPPS_NAME, SHINYAPPS_TOKEN, SHINYAPPS_SECRET)"; exit 1; } + [ -n "$SHINYAPPS_APPNAME" ] && [ -n "$SHINYAPPS_NAME" ] && [ -n "$SHINYAPPS_TOKEN" ] && [ -n "$SHINYAPPS_SECRET" ] || { echo "Error: Missing required secrets (SHINYAPPS_APPNAME, SHINYAPPS_NAME, SHINYAPPS_TOKEN, SHINYAPPS_SECRET)"; exit 1; } - name: Checkout repository uses: actions/checkout@v4 @@ -38,28 +41,74 @@ jobs: with: r-version: '4.5.1' - - name: Capture CRAN snapshot from R release date - uses: ./.github/actions/capture-cran-snapshot - - - name: Configure CRAN snapshot - uses: r-lib/actions/setup-r@v2 + - name: Cache R packages + id: cache-r-packages + uses: actions/cache@v4 with: - r-version: '4.5.1' - install-r: false - cran: "${{ env.CRAN_SNAPSHOT }}" + path: ~/R/deploy-library + key: ${{ runner.os }}-r-4.5.1-deploy-${{ hashFiles('DESCRIPTION') }} + restore-keys: | + ${{ runner.os }}-r-4.5.1-deploy- + ${{ runner.os }}-r-4.5.1- - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: any::rsconnect,any::desc - cache: true - cache-version: ubuntu-r-4-5-1 + - name: Configure cached R library + run: | + deploy_lib="${HOME}/R/deploy-library" + mkdir -p "$deploy_lib" + echo "R_LIBS_USER=$deploy_lib" >> "$GITHUB_ENV" + + - name: Install deployment packages + env: + PKG_CACHE_HIT: ${{ steps.cache-r-packages.outputs.cache-hit }} + run: | + deploy_lib <- Sys.getenv("R_LIBS_USER", unset = "") + if (deploy_lib == "") { + deploy_lib <- file.path(Sys.getenv("HOME"), "R", "deploy-library") + } + dir.create(deploy_lib, recursive = TRUE, showWarnings = FALSE) + .libPaths(c(deploy_lib, .libPaths())) + cache_hit <- identical(Sys.getenv("PKG_CACHE_HIT"), "true") + repos <- getOption("repos") + if (!cache_hit) { + try(remove.packages(desc::desc_get("Package")), silent = TRUE) + if (!"remotes" %in% rownames(installed.packages())) install.packages("remotes", repos = repos) + pkgs <- c("sessioninfo", "desc", "devtools", "golem", "pkgload", "rsconnect", "usethis") + remotes::install_cran(pkgs, repos = repos, upgrade = "never", force = FALSE) + remotes::install_deps(dependencies = TRUE) + deps <- rsconnect::appDependencies(appDir = ".", appFiles = "DESCRIPTION", appMode = "shiny") + bad <- deps[is.na(deps$Source) | deps$Source %in% c("unknown", "source"), , drop = FALSE] + if (nrow(bad)) remotes::install_cran(bad$Package, repos = repos, upgrade = "never", force = TRUE) + } else { + message("Cache hit detected for deployment library; skipping reinstalls.") + } + deps <- rsconnect::appDependencies(appDir = ".", appFiles = "DESCRIPTION", appMode = "shiny") + print(deps) + shell: Rscript {0} - - name: Deploy to shinyapps.io + - name: Deploy to shinyapps.io (as package) env: R_CONFIG_ACTIVE: production - CRAN_SNAPSHOT: ${{ env.CRAN_SNAPSHOT }} + SHINYAPPS_APPNAME: ${{ secrets.SHINYAPPS_APPNAME || vars.SHINYAPPS_APPNAME }} SHINYAPPS_NAME: ${{ secrets.SHINYAPPS_NAME || vars.SHINYAPPS_NAME }} SHINYAPPS_TOKEN: ${{ secrets.SHINYAPPS_TOKEN || vars.SHINYAPPS_TOKEN }} SHINYAPPS_SECRET: ${{ secrets.SHINYAPPS_SECRET || vars.SHINYAPPS_SECRET }} run: | - Rscript inst/tasks/deploy_to_shinyio.R + deploy_lib <- Sys.getenv("R_LIBS_USER", unset = "") + if (deploy_lib != "") { + dir.create(deploy_lib, recursive = TRUE, showWarnings = FALSE) + .libPaths(c(deploy_lib, .libPaths())) + } + # Prepare the application for deployment + unlink(c(".Rprofile", ".git", ".github"), recursive = TRUE, force = TRUE) + # Add shinyapps.io file + golem::add_shinyappsio_file(open = FALSE) + # Remove package namespace from app.R to prevent rsconnect from treating it as a non-reproducible source dependency + pkg_name <- desc::desc_get("Package") + app_content <- paste(readLines("app.R", encoding = "UTF-8"), collapse = "\n") + pattern <- paste0(pkg_name, "::run_app()") + app_content_clean <- gsub(pattern, "run_app()", app_content, fixed = TRUE) + writeLines(app_content_clean, con = "app.R", useBytes = TRUE) + # Deploy the application + rsconnect::setAccountInfo(name = Sys.getenv('SHINYAPPS_NAME'), token = Sys.getenv('SHINYAPPS_TOKEN'), secret = Sys.getenv('SHINYAPPS_SECRET')) + rsconnect::deployApp(appName = Sys.getenv('SHINYAPPS_APPNAME'), forceUpdate = TRUE, logLevel = 'normal', launch.browser = FALSE, appMode = "shiny") + shell: Rscript {0} diff --git a/.gitignore b/.gitignore index 3e18601..abf16b5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ rsconnect/ man/ scratchpad.md inst/doc -/.quarto/ \ No newline at end of file +/.quarto/ +*.html diff --git a/DESCRIPTION b/DESCRIPTION index af62d2a..f512a9b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,20 +1,23 @@ Package: whitelabel Title: An Amazing Shiny App -Version: 0.0.0.9000 +Version: 0.1.0.9000 Authors@R: - person(given = "bilbo", - family = "baggins", - role = c("aut", "cre"), - email = "BBaggins@tonkintaylor.co.nz") + person("bilbo", "baggins", , "BBaggins@tonkintaylor.co.nz", role = c("aut", "cre")) Description: What the package does (one paragraph). License: file LICENSE -Imports: +Imports: config, + fresh, golem, shiny -Encoding: UTF-8 -LazyData: true -RoxygenNote: 7.3.3 Suggests: + knitr, + pkgload, + rmarkdown, testthat (>= 3.0.0) +VignetteBuilder: + knitr Config/testthat/edition: 3 +Encoding: UTF-8 +LazyData: true +RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index 88ec92c..27c728e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,9 +1,23 @@ # Generated by roxygen2: do not edit by hand +export(app_server) +export(app_ui) +export(run_app) import(shiny) +importFrom(fresh,adminlte_color) +importFrom(fresh,adminlte_global) +importFrom(fresh,create_theme) importFrom(golem,activate_js) importFrom(golem,add_resource_path) importFrom(golem,bundle_resources) importFrom(golem,favicon) +importFrom(golem,with_golem_options) importFrom(shiny,NS) +importFrom(shiny,fluidPage) +importFrom(shiny,moduleServer) +importFrom(shiny,plotOutput) +importFrom(shiny,renderPlot) +importFrom(shiny,shinyApp) +importFrom(shiny,sidebarLayout) +importFrom(shiny,sliderInput) importFrom(shiny,tagList) diff --git a/R/AGENTS.md b/R/AGENTS.md new file mode 100644 index 0000000..b87e6e1 --- /dev/null +++ b/R/AGENTS.md @@ -0,0 +1,26 @@ +# Data Wrangling + +- Prefer using packages from the "tidyverse" collection for data manipulation tasks. This includes using "dplyr" for data frame operations, "tibble" for enhanced data frames, and "readr" for reading data files. +- Prefer using "purrr" for functional programming tasks, such as mapping functions over lists or vectors. + +## Namespace Management + +- **Prefer `@importFrom` over `@import`**: Always use `@importFrom package_name specific_functions` rather than `@import package_name` for all packages. This keeps the namespace clean and makes dependencies explicit by only importing the functions actually used in the codebase. +- **Centralize imports in `r1099894-package.R`**: Declare all `@importFrom` statements in `R/r1099894-package.R` instead of in individual function roxygen comments. This provides a single source of truth for all package dependencies. +- **Exception**: Only use `@import` in rare cases where a package is used extensively throughout the codebase (e.g., `rlang` for error handling utilities across many functions). + +## Shiny App + +- We are using the "golem" framework for building the Shiny application. Use the context7 mcp tool to get more context on how to use golem functions and structure your app. +- **Colour customization**: Update colour constants in `R/app-themes-constants.R` and use `create_app_theme()` in `R/app-themes.R` to apply them to the dashboard. +- **Theme customization via `app-themes.R`**: All dashboard theme customization (colors, layouts, backgrounds) should be done through the `create_app_theme()` function in `R/app-themes.R`. Before adding custom CSS or other styling methods, check `app-themes.R` first—it likely already provides a way to control the element you want to change. The function uses the `fresh` package to manage: + - Box header colors (via `adminlte_color()`) + - Dashboard backgrounds and box backgrounds (via `adminlte_global()`) + - Sidebar styling (via `adminlte_sidebar()`) + - Other AdminLTE theme variables + - See `R/app-themes.R` for current theme configuration and available customization options. +- **Using `fresh` themes in Shiny**: Call `use_theme(create_app_theme())` directly inside `dashboardBody()`, not in `tagList()`. The theme function must be invoked where it's applied (inside the body element), not stored as a variable in the tagList wrapper. + +- **Icons for UI elements**: Always use `icon()` (from Shiny/Font Awesome) instead of emoji symbols or special Unicode characters to avoid non-ASCII characters in R code. Examples: Use `icon("info-circle")` instead of "ℹ️" + +- **Controlbar widget width**: When adding widgets to the controlbar, always set `width = "100%"` for widgets that provide a width argument so they align with the panel. diff --git a/R/app-constants.R b/R/app-constants.R new file mode 100644 index 0000000..e69de29 diff --git a/R/app-server.R b/R/app-server.R index d6661e0..53cc8d2 100644 --- a/R/app-server.R +++ b/R/app-server.R @@ -1,9 +1,9 @@ + #' The application server-side #' -#' @param input,output,session Internal parameters for {shiny}. +#' @param input,output,session Internal parameters for \{shiny\}. #' DO NOT REMOVE. -#' @import shiny -#' @noRd +#' @export app_server <- function(input, output, session) { # Your application server logic mod_histogram_server("histogram_1") diff --git a/R/app-themes-constants.R b/R/app-themes-constants.R new file mode 100644 index 0000000..1e38c16 --- /dev/null +++ b/R/app-themes-constants.R @@ -0,0 +1,69 @@ +# ============================================================================ +# Application Theme Colour Constants +# ============================================================================ + +#' Header and Footer Background Colour +#' +#' The primary background color used for both the dashboard header (navbar) +#' and footer elements to maintain visual consistency. +#' +#' @keywords internal +HEADER_FOOTER_BG_COLOR <- "#2C3E50" + +#' Transfer Coefficients Input Box Colour +#' +#' Accent color for the "info" status boxes used in the Transfer Coefficients +#' module (left panel). +#' +#' @keywords internal +INFO_BOX_COLOR <- "#5485B8" + +#' Depletion Curves Output Box Colour +#' +#' Accent color for the "warning" status boxes used in the Depletion Curves +#' module (right panel). +#' +#' @keywords internal +OUTPUT_BOX_COLOR <- "#E59A4F" + +#' Dashboard Content Background Colour +#' +#' Background color for the main dashboard content area. +#' +#' @keywords internal +CONTENT_BG_COLOR <- "#ECF0F1" + +#' Dashboard Box Background Colour +#' +#' Background color for individual boxes and containers within the dashboard. +#' +#' @keywords internal +BOX_BG_COLOR <- "#FFFFFF" + +#' Sidebar Section Heading Colour +#' +#' Text color for sidebar section headings (Calculators, Tools, Help). +#' +#' @keywords internal +SIDEBAR_HEADING_COLOR <- "#FFFFFF" + +#' Sidebar Section Heading Padding +#' +#' CSS padding for sidebar section headings. +#' +#' @keywords internal +SIDEBAR_HEADING_PADDING <- "15px 15px 10px" + +#' Sidebar Width in Pixels +#' +#' Width applied to the dashboard sidebar and corresponding layout offsets. +#' +#' @keywords internal +SIDEBAR_WIDTH_PX <- 280 + +#' Controlbar Width in Pixels +#' +#' Width applied to the dashboard controlbar and corresponding layout offsets. +#' +#' @keywords internal +CONTROLBAR_WIDTH_PX <- 320 diff --git a/R/app-themes.R b/R/app-themes.R new file mode 100644 index 0000000..2b35140 --- /dev/null +++ b/R/app-themes.R @@ -0,0 +1,36 @@ +# ============================================================================ +# Main Theme Function +# ============================================================================ + +#' Create Application Theme +#' +#' Creates a custom theme for the Pūwaiwaha dashboard using the `fresh` package. +#' Defines colors for input and output boxes, the page header, and footer to maintain +#' visual consistency across the application. +#' +#' Colour constants are defined in `app-themes-constants.R`. +#' +#' @return A theme object suitable for use with `fresh::use_theme()` in dashboardBody +#' +#' @details +#' This theme customizes AdminLTE color variables: +#' - `light_blue`: Controls the header and footer background colour +#' - `aqua`: Remapped to input boxes via `status = "info"` (Transfer Coefficients) +#' - `orange`: Maps to output boxes via `status = "warning"` (Depletion Curves) +#' +#' Footer styling is applied via custom CSS (`get_footer_css()`) to match the header. +#' +#' @noRd +create_app_theme <- function() { + create_theme( + adminlte_color( + light_blue = HEADER_FOOTER_BG_COLOR, # Header and footer background + aqua = INFO_BOX_COLOR, # Info status → Input boxes + orange = OUTPUT_BOX_COLOR # Warning status → Output boxes + ), + adminlte_global( + content_bg = CONTENT_BG_COLOR, # Dashboard body background + box_bg = BOX_BG_COLOR # Box content background + ) + ) +} \ No newline at end of file diff --git a/R/app-ui.R b/R/app-ui.R index 31183e3..1aa72ae 100644 --- a/R/app-ui.R +++ b/R/app-ui.R @@ -1,9 +1,8 @@ #' The application User-Interface #' -#' @param request Internal parameter for `{shiny}`. +#' @param request Internal parameter for \{shiny\}. #' DO NOT REMOVE. -#' @import shiny -#' @noRd +#' @export app_ui <- function(request) { tagList( # Leave this function for adding external resources diff --git a/R/run_app.R b/R/run_app.R index 8b13789..b13c063 100644 --- a/R/run_app.R +++ b/R/run_app.R @@ -1 +1,26 @@ - +#' Run the Shiny Application +#' +#' @param ... arguments to pass to golem_opts. +#' See `?golem::get_golem_options` for more details. +#' @inheritParams shiny::shinyApp +#' +#' @export +run_app <- function( + onStart = NULL, + options = list(), + enableBookmarking = NULL, + uiPattern = "/", + ... +) { + with_golem_options( + app = shinyApp( + ui = app_ui, + server = app_server, + onStart = onStart, + options = options, + enableBookmarking = enableBookmarking, + uiPattern = uiPattern + ), + golem_opts = list(...) + ) +} diff --git a/R/whitelabel-package.R b/R/whitelabel-package.R new file mode 100644 index 0000000..70fcf53 --- /dev/null +++ b/R/whitelabel-package.R @@ -0,0 +1,12 @@ +#' @keywords internal +"_PACKAGE" + +## usethis namespace: start +#' @importFrom fresh adminlte_color adminlte_global create_theme +#' @importFrom golem add_resource_path bundle_resources favicon with_golem_options +#' @importFrom shiny fluidPage moduleServer NS plotOutput renderPlot shinyApp sidebarLayout sliderInput tagList +## usethis namespace: end +NULL + +# Suppress R CMD check notes for tidyverse data masking pronoun +utils::globalVariables(c(".data")) \ No newline at end of file diff --git a/app.R b/app.R deleted file mode 100644 index 3c22ec1..0000000 --- a/app.R +++ /dev/null @@ -1,14 +0,0 @@ -# Launch the ShinyApp (Do not edit this file!) -pkgload::load_all(export_all = TRUE, helpers = FALSE, attach_testthat = FALSE) -options("golem.app.prod" = TRUE) - golem::with_golem_options( - app = shinyApp( - ui = app_ui, - server = app_server, - onStart = NULL, - options = list(), - enableBookmarking = NULL, - uiPattern = "/" - ), - golem_opts = list() -) diff --git a/inst/tasks/deploy_to_shinyio.R b/inst/tasks/deploy_to_shinyio.R index 8bdc9ec..05720ba 100644 --- a/inst/tasks/deploy_to_shinyio.R +++ b/inst/tasks/deploy_to_shinyio.R @@ -18,53 +18,18 @@ library(desc) Sys.setenv(RENV_CONFIG_SNAPSHOT_VALIDATE = "FALSE") options(renv.warnings.unknown_sources = FALSE) -# Note: We do NOT install the package locally to avoid renv validation issues -# The deployment will handle dependencies from DESCRIPTION file +# Note: The package is now installed via remotes::install_local() in the CI workflow +# (see .github/workflows/deploy-shinyapps.yaml) before this script runs. +# This ensures system.file() and other package-aware functions work correctly. # Load credentials from .Renviron if (file.exists(".Renviron")) readRenviron(".Renviron") -# Get credentials -account_name <- Sys.getenv("SHINYAPPS_NAME") -account_token <- Sys.getenv("SHINYAPPS_TOKEN") -account_secret <- Sys.getenv("SHINYAPPS_SECRET") - -# Validate -if (account_name == "" || account_token == "" || account_secret == "") { - stop("Missing credentials in .Renviron. Set SHINYAPPS_NAME, SHINYAPPS_TOKEN, SHINYAPPS_SECRET") -} - -# Configure account (only if not already set) -if (!account_name %in% rsconnect::accounts()$name) { - rsconnect::setAccountInfo( - name = account_name, - token = account_token, - secret = account_secret - ) -} - -# Get app info from DESCRIPTION -app_name <- desc::desc_get_field("Package") +# Add shinyapps.io deployment file +golem::add_shinyappsio_file(open = FALSE) # Deploy # Use appPrimaryDoc to treat this as a document deployment (not a package) -# This prevents rsconnect from trying to reinstall the whitelabel package -rsconnect::deployApp( - appName = app_name, - appFiles = c( - "R/", - "inst/app", - "inst/golem-config.yml", - "NAMESPACE", - "DESCRIPTION", - "app.R" - ), - appPrimaryDoc = "app.R", - account = account_name, - forceUpdate = TRUE, - lint = FALSE, - launch.browser = FALSE -) - -# Print URL -cat(sprintf("\nDeployed to: https://%s.shinyapps.io/%s/\n", account_name, app_name)) +# This prevents rsconnect from trying to reinstall the r1099894 package +rsconnect::setAccountInfo(name = Sys.getenv('SHINYAPPS_NAME'), token = Sys.getenv('SHINYAPPS_TOKEN'), secret = Sys.getenv('SHINYAPPS_SECRET')) +rsconnect::deployApp(appName = Sys.getenv('SHINYAPPS_APPNAME'), forceUpdate = TRUE, logLevel = 'normal', launch.browser = FALSE, appMode = "shiny") diff --git a/inst/tasks/run_local.R b/inst/tasks/run_local.R index d42f2c5..06ae1d7 100644 --- a/inst/tasks/run_local.R +++ b/inst/tasks/run_local.R @@ -1,34 +1,19 @@ -#!/usr/bin/env Rscript - -#' Run Golem Application Locally with Hot Reload -#' -#' Development script that launches the app with auto-reload capabilities. -#' Changes to R/ and inst/ files trigger app reload automatically. -#' -#' Usage: -#' source("inst/tasks/run_local.R") -#' # or -#' Rscript inst/tasks/run_local.R +############################################################################### +# Run Golem Application Locally with Hot Reload +############################################################################### # Set development options for hot reload -options(shiny.autoreload = TRUE) # Auto-reload when files change -options(shiny.port = httpuv::randomPort()) # Random port to avoid caching - -# Install missing packages if needed -pkgs_needed <- c("shiny", "golem", "config") -pkgs_missing <- pkgs_needed[!pkgs_needed %in% rownames(utils::installed.packages())] -if (length(pkgs_missing) > 0) { - cat("Installing missing packages:", paste(pkgs_missing, collapse = ", "), "\n") - install.packages(pkgs_missing) -} +options( + shiny.autoreload = TRUE, + shiny.port = 1023 +) # Load the package in development mode -cat("Loading package in development mode...\n") -devtools::load_all(here::here()) +pkgload::load_all(here::here(), export_all = FALSE, export_imports = FALSE) # Configure golem with development settings golem::with_golem_options( - app = shiny::shinyApp( + app = shinyApp( ui = app_ui, server = app_server ), @@ -36,3 +21,4 @@ golem::with_golem_options( app_prod = FALSE # Development mode ) ) + diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..b771bff --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,13 @@ +# Test Guidelines + +## One Assertion Per Test + +- Each test must have exactly one `expect_*()` call. +- Combine related assertions using composite expectations (e.g., list comparisons) if needed, but keep the test atomic. + +## Behavior-Focused Testing + +- Tests should focus on behaviour and outputs rather than implementation details. +- Tests will verify return types, error conditions, and key properties without examining internal structure. +- Avoid coupling tests to function implementation details (e.g., column names, internal data ordering). +- A test passes if the function returns the correct type, raises the expected error, or meets the contract—regardless of how it achieves that internally. diff --git a/vignettes/.gitignore b/vignettes/.gitignore new file mode 100644 index 0000000..4ad458b --- /dev/null +++ b/vignettes/.gitignore @@ -0,0 +1,7 @@ +*.html +*.R + +/.quarto/ +*.pdf +*.tex +*.log diff --git a/vignettes/adr/adr-001-module-structure-and-organisation.Rmd b/vignettes/adr/adr-001-module-structure-and-organisation.Rmd new file mode 100644 index 0000000..74fbb95 --- /dev/null +++ b/vignettes/adr/adr-001-module-structure-and-organisation.Rmd @@ -0,0 +1,184 @@ +--- +title: "ADR-001: Module Structure and Organisation" +--- + +## Decision + +Adopt a single-file-per-module organisation where each Shiny module is defined entirely within one file. Each module file contains both UI and server functions as separate, named functions rather than spreading module logic across multiple files. Module files follow the naming convention (`mod--.R`), and both the UI and server functions use consistent naming: `mod___ui()` and `mod___server()`. + +## Status + +Proposed — 14 November 2025. + +## Context + +When implementing Shiny modules, developers commonly face two structural choices within R's constraints: + +1. **Two-file approach:** Define UI functions in `mod--_ui.R` and server functions in `mod--_server.R`, both in the flat `R/` directory. This doubles the file count and scatters related logic. +2. **Single-file approach:** Define both UI and server functions in one file, keeping related logic together and reducing file clutter. + +R package standards require all functions to be defined in files at the top level of `R/`; subdirectories are ignored during package loading and roxygen2 documentation generation. This constraint makes a scoped naming convention essential to distinguish between multiple modules. + +With multiple modules across different domains, the choice of organisation significantly impacts: +- **File count and workspace clutter:** Two-file approach leads to many files; single-file keeps it to fewer files. +- **Code discoverability:** Developers quickly locate all module logic by opening one file. +- **Maintenance:** Changes to a module stay localised to one file. +- **R package conventions:** Standard R package structure prefers a flat `R/` directory; subdirectories are discouraged. + +## Options Considered + +1. **Two files per module (separate UI/server in flat `R/` directory)** — Define UI functions in `mod--_ui.R` and server functions in `mod--_server.R`, both at the top level of `R/`. + - Drawback: Doubles file count (~48+ files); separates related logic across two files; requires developers to open and navigate between two files when modifying a module. + +2. **Single-file-per-module (UI + server in one file)** — Define both UI and server functions in a single file named `mod--.R`. + - Benefit: Keeps related logic together; reduces file clutter; easier to navigate and refactor; fewer files to manage. + - Adopted option. + +3. **Single monolithic file for all modules** — Define all UI and server functions in one or two large files. + - Drawback: Difficult to navigate; hard to maintain; poor discoverability; extremely difficult to split responsibilities. + +4. **Subdirectories by domain** — Organise files as `R/domain/mod-feature.R`. + - Drawback: R package standards prohibit subdirectories in `R/`; files in subdirectories are ignored during package loading and roxygen2 documentation generation. Only functions at the top level of `R/` are sourced automatically. + +## Decision Rationale + +**Single-File Organisation:** +Each module is defined in exactly one file containing: +- One or more UI functions: `mod___ui(id)` for primary input/display, and optionally `mod____ui(id)` for additional UI sections (e.g., output/display), where `` is inserted between `` and the `_ui` suffix (e.g., `mod_domain_feature_subfeature_ui(id)`). +- One or more server functions: + - For primary logic: `mod___server(id, ...)` (e.g., `mod_domain_feature_server(id, params)`) + - For additional server sections with a specific purpose: `mod____server(id, ...)` (e.g., `mod_domain_feature_subfeature_server(id, data)` for output/display) +- Any helper utilities specific to that module (e.g., `domain_feature_helper()`) + +**Example:** A module may contain both input controls and output displays (e.g., input UI + output UI), with corresponding server functions for handling input reactives and computing output plots. + +**File Naming:** +Follows naming conventions: +- `R/mod--.R` + +Example structure: +``` +R/ + mod-domain-feature_a.R (UI + server for Feature A) + mod-domain-feature_b.R (UI + server for Feature B) + mod-tools-utility.R (UI + server for Utility tool) + ... +``` + +**Function Naming Within Files:** +Both UI and server functions use scoped prefixes: + +```r +# File: mod-domain-feature.R +# ============================================================================ +# UI: Feature Input +# ============================================================================ +mod_domain_feature_ui <- function(id) { + ns <- NS(id) + tagList( + # Input UI content here + ) +} + +# ============================================================================ +# UI: Feature Output +# ============================================================================ +mod_domain_feature_output_ui <- function(id) { + ns <- NS(id) + tagList( + # Output UI content here + ) +} + +# ============================================================================ +# SERVER: Feature Input +# ============================================================================ +mod_domain_feature_server <- function(id, params) { + moduleServer(id, function(input, output, session) { + # Input server logic here + }) +} + +# ============================================================================ +# SERVER: Feature Output +# ============================================================================ +mod_domain_feature_output_server <- function(id, data) { + moduleServer(id, function(input, output, session) { + # Output server logic here + }) +} + +# ============================================================================ +# Helper utilities (internal to this module) +# ============================================================================ +domain_feature_helper <- function(arg) { + # Implementation +} +``` + +**Rationale:** +- **Simplicity:** One file per module is intuitive and easy to locate. +- **Discoverability:** All module logic is in one place; developers open the file and see both UI and server. +- **Maintainability:** Changes to a module don't require touching multiple files; reduces risk of inconsistency. +- **R Package Standards:** Keeps the `R/` directory flat, avoiding violations of R package conventions. +- **Scalability:** With 24+ calculators, single-file organisation keeps the `R/` directory manageable (~27 files vs. 50+). +- **Testing:** Tests can import one module file and test both UI and server logic together. +- **Documentation:** Roxygen2 comments naturally group module functions (UI, server, helpers) by prefix, improving visibility in generated `.Rd` files. + +### Trade-offs + +**File Size vs. Code Organisation:** +Some modules may grow beyond 200 lines. This is acceptable; developers should: +- Use clear section comments to separate UI, server, and helper logic +- Extract large shared utilities into separate module-level helper files if needed (e.g., `mod-domain-common-utilities.R`) +- Consider splitting a complex module into multiple logical sub-modules if it becomes unwieldy + +**Shared Logic Across Modules:** +If multiple modules within the same domain require identical logic: +- Extract shared utilities into a separate file: `R/domain-common-utilities.R` with functions prefixed `domain_*` +- Alternatively, define shared logic in an existing helper module +- Avoid unnecessary duplication; prioritise DRY principles + +## Consequences + +**Positive:** +- Single file per module is intuitive and requires no file navigation overhead. +- All related logic (UI, server, helpers) is co-located, reducing context switching. +- Fewer files in `R/` directory reduces clutter and simplifies project navigation. +- Follows R package standards and conventions. +- Easier to refactor: rename or split a module without managing multiple file locations. +- Roxygen2 documentation groups related functions by prefix, improving `.Rd` visibility. + +**Negative:** +- File size may grow as a module becomes more complex (e.g., 200+ lines). +- No forced separation of concerns between UI and server logic (developer discipline required to keep code organised). +- If two modules share significant logic, refactoring shared code requires additional helper utilities. + +## Follow-up Actions + +1. **Audit existing modules:** Review all current Shiny modules and ensure they follow single-file-per-module organisation. + +2. **Create new modules per convention:** When implementing new modules, follow the single-file template: + - File: `R/mod--.R` + - Functions: `mod___ui()`, `mod___server()` + - Helpers: `__helper_function()` + +3. **Shared utility modules:** If a domain requires multiple shared helpers across modules, optionally create a utility module: + - File: `R/mod-domain-common-utilities.R` (optional; only if substantial shared logic exists) + - Functions: `domain_shared_helper()`, etc. + +4. **Documentation template:** Create a template in `R/mod-template.R` demonstrating the single-file structure: + - Shows UI function anatomy + - Shows server function anatomy + - Includes roxygen2 boilerplate + - Comments on separation concerns within the file + +5. **Naming consistency review:** Ensure all new and refactored modules follow these conventions. + +6. **Link ADRs:** Reference this ADR from the package README/DEVELOPMENT guide for visibility. + +## References + +- **Style Guide:** `.github/copilot-instructions.md` +- **Shiny Module Documentation:** https://shiny.posit.co/r/articles/modules/ + diff --git a/vignettes/adr/adr-002-dashboard-page-component-extraction.Rmd b/vignettes/adr/adr-002-dashboard-page-component-extraction.Rmd new file mode 100644 index 0000000..ffcf783 --- /dev/null +++ b/vignettes/adr/adr-002-dashboard-page-component-extraction.Rmd @@ -0,0 +1,133 @@ +--- +title: "ADR-004: Dashboard Page Component Extraction" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{ADR-004: Dashboard Page Component Extraction} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +## Decision + +Extract `dashboardSidebar`, `dashboardBody`, and the controlbar into separate module-like functions with `app-page-*` naming convention to improve code organization and maintainability of the main application UI. + +## Status + +**Accepted** (2025-11-14) - **Implementation Complete** + +## Context + +The original `R/app-ui.R` file contained over 350 lines of code with significant UI complexity: + +- `dashboardSidebar` block: 68 lines of nested menu structure +- `dashboardBody` block: 150+ lines of repetitive `tabItems()` definitions +- All combined into a single monolithic file that became difficult to navigate and maintain + +The existing `mod_controlbar.R` module demonstrated the effectiveness of extracting complex components into separate files, following Shiny's module pattern for code organization. + +### Constraints and Objectives + +- **Maintainability:** Reduce cognitive load when working with the UI +- **Scalability:** Enable easier addition/modification of sidebar items or tabs +- **Consistency:** Follow the established pattern used by `mod_controlbar.R` +- **No Breaking Changes:** Maintain full backward compatibility with existing server-side logic +- **Internal Functions Only:** All extracted functions use `@keywords internal` (no NAMESPACE exports needed) + +## Options Considered + +**Option A: Keep all code in `app-ui.R` (Status quo)** +- Pros: Single file, no additional imports +- Cons: Large file becomes harder to maintain, difficult to locate specific sections + +**Option B: Extract into separate files with `app-page-*` naming (Selected)** +- Pros: Clear separation of concerns, easier to navigate, consistent with non-module components +- Cons: Adds two new files to R/ directory + +**Option C: Extract into `mod_*` files** +- Pros: Follows Shiny module naming conventions +- Cons: These components are not Shiny modules (they don't use `moduleServer`), naming would be misleading + +## Decision Rationale + +**Option B** was selected because: + +1. **Semantic Accuracy:** Components like sidebar and body are not Shiny modules, so `mod_*` naming is semantically incorrect +2. **Naming Convention:** The `app-page-*` prefix accurately describes these as core application page components +3. **Consistency:** Aligns with the refactoring of controlbar from `mod_controlbar` to `app_page_controlbar` +4. **Maintainability:** Extracts complex UI logic into focused, single-purpose files +5. **No Overhead:** Internal functions require no NAMESPACE management + +## Consequences + +### Positive + +- ✅ Improved code organization and readability +- ✅ Easier to locate and modify specific UI sections +- ✅ Better test-ability of individual components +- ✅ Reduced complexity in the main `app_ui()` function +- ✅ No NAMESPACE changes required (all functions are internal) +- ✅ No additional dependencies or imports + +### Negative + +- ⚠️ Three additional R files (though minimal overhead) +- ⚠️ Developers need to understand where each component lives + +### Neutral + +- ○ `app-ui.R` now primarily serves as a layout orchestrator rather than implementation + +## Implementation Details + +### Files Created + +#### `R/app-page-sidebar.R` +- Function: `app_page_sidebar_ui()` +- Returns: Complete `dashboardSidebar()` component with navigation structure +- Lines extracted: 68 lines from original `app-ui.R` + +#### `R/app-page-body.R` +- Function: `app_page_body_ui()` +- Returns: Complete `dashboardBody()` component with all tab items +- Lines extracted: 150+ lines from original `app-ui.R` + +#### `R/app-page-controlbar.R` +- **Renamed from:** `R/mod-controlbar.R` +- Functions: + - `app_page_controlbar_ui()` (formerly `mod_controlbar_ui()`) + - `app_page_controlbar_server()` (formerly `mod_controlbar_server()`) +- Maintains full backward compatibility with existing server-side logic + +### Files Modified + +#### `R/app-ui.R` +- Line 26-93: Replaced with `app_page_sidebar_ui()` +- Line 95-244: Replaced with `app_page_body_ui()` +- Line 247: Updated from `mod_controlbar_ui()` to `app_page_controlbar_ui()` +- **Result:** Reduced from 367 lines to ~80 lines + +#### `R/app-server.R` +- Line 12: Updated from `mod_controlbar_server()` to `app_page_controlbar_server()` +- No functional changes to logic + +### Files Deleted + +#### `R/mod-controlbar.R` +- Replaced by `R/app-page-controlbar.R` + +## Follow-up Actions + +1. ✅ Created `R/app-page-sidebar.R` +2. ✅ Created `R/app-page-body.R` +3. ✅ Created `R/app-page-controlbar.R` +4. ✅ Updated `R/app-ui.R` +5. ✅ Updated `R/app-server.R` +6. ✅ Deleted `R/mod-controlbar.R` +7. ⬜ Run `devtools::document()` to regenerate NAMESPACE +8. ⬜ Run test suite to verify functionality preserved +9. ⬜ Review UI rendering in application + +## References + +- Related ADR: `adr-001-module-structure-and-organisation.Rmd` +- Shiny modules guide: https://shiny.posit.co/r/articles/improve-user-experience/modules/ diff --git a/vignettes/adr/adr-template.Rmd b/vignettes/adr/adr-template.Rmd new file mode 100644 index 0000000..2232dd9 --- /dev/null +++ b/vignettes/adr/adr-template.Rmd @@ -0,0 +1,35 @@ +# Architecture Decision Record Template + +## Decision + +Describe the architectural choice in one or two sentences. + +## Status + +State whether the decision is proposed, accepted, deprecated, or superseded. Include the decision date. + +## Context + +Summarise the forces leading to this decision. Capture relevant background, constraints, and objectives. + +## Options Considered + +- Option A +- Option B +- Option C + +## Decision Rationale + +Explain why the selected option best meets the context, considering trade-offs and rejected alternatives. + +## Consequences + +Detail the positive and negative outcomes expected from this decision. + +## Follow-up Actions + +List any tasks required to implement the decision or monitor its impact. + +## References + +Provide links to code, issues, or documentation that informed the decision. diff --git a/vignettes/placeholder.Rmd b/vignettes/placeholder.Rmd new file mode 100644 index 0000000..b820ef3 --- /dev/null +++ b/vignettes/placeholder.Rmd @@ -0,0 +1,19 @@ +--- +title: "placeholder" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{placeholder} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +```{r setup} +pkgload::load_all() +```