library(shiny) library(shinydashboard) library(DT) library(dplyr) library(shinyjs) library(readr) library(webshot2) library(htmlwidgets) ui <- dashboardPage( dashboardHeader(title = "Pitching Tracker"), dashboardSidebar( sidebarMenu( menuItem("Bullpen Session", tabName = "bullpen", icon = icon("baseball")), menuItem("Game Tracking", tabName = "game", icon = icon("gamepad")), menuItem("Leaderboard", tabName = "leaderboard", icon = icon("chart-bar")), menuItem("Export Data", tabName = "export", icon = icon("download")) ) ), dashboardBody( useShinyjs(), tags$head( tags$style(HTML(" .zone-btn { width: 100%; min-height: 80px; font-size: 28px; font-weight: bold; margin: 2px; } .border-zone-btn { min-height: 60px; font-size: 20px; font-weight: bold; } .zone-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; max-width: 400px; margin: 0 auto; } .selection-display { background: #f0f0f0; padding: 15px; border-radius: 10px; margin: 10px 0; min-height: 60px; font-size: 16px; font-weight: bold; } ")) ), tabItems( # BULLPEN TAB - SIMPLIFIED tabItem( tabName = "bullpen", fluidRow( box( title = "Pitcher Setup", status = "primary", solidHeader = TRUE, width = 4, textInput("pitcherName", "Pitcher Name:", placeholder = "Enter name"), actionButton("addPitcher", "Add Pitcher", class = "btn-success btn-block"), hr(), selectInput("currentPitcher", "Currently Throwing:", choices = NULL), selectInput("batterSide", "Batter Side:", choices = c("Right (Default)" = "R", "Left" = "L"), selected = "R"), hr(), div( style = "background: #f4f4f4; padding: 15px; border-radius: 10px;", h4("Current Stats"), verbatimTextOutput("currentStats") ) ), box( title = "Record Pitch", status = "success", solidHeader = TRUE, width = 8, div(class = "selection-display", uiOutput("selectionDisplay")), h4("1. Pitch Type"), fluidRow( column(4, actionButton("pitch_fb", "Fastball", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("pitch_cb", "Curveball", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("pitch_ch", "Changeup", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) ), fluidRow( column(4, actionButton("pitch_sl", "Slider", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("pitch_ct", "Cutter", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("pitch_sn", "Sinker", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")) ), hr(), h4("2. Intended Location"), p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"), fluidRow( column(6, actionButton("int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")), column(6, actionButton("int_down", "DOWN", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) ), fluidRow( column(4, actionButton("int_away", "AWAY", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("int_middle", "MIDDLE", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("int_in", "IN", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")) ), hr(), h4("3. Result"), fluidRow( column(6, actionButton("result_strike", "STRIKE", class = "btn-success", style = "width: 100%; min-height: 80px; font-size: 24px;")), column(6, actionButton("result_ball", "BALL", class = "btn-danger", style = "width: 100%; min-height: 80px; font-size: 24px;")) ), hr(), actionButton("clearPitch", "Clear Selection", class = "btn-warning btn-block") ) ), fluidRow( box( title = "Recent Pitches", status = "info", solidHeader = TRUE, width = 12, DT::dataTableOutput("recentPitches") ) ) ), # GAME TAB - NEW tabItem( tabName = "game", fluidRow( box( title = "Game Setup", status = "primary", solidHeader = TRUE, width = 4, textInput("gamePitcherName", "Pitcher Name:", placeholder = "Enter name"), actionButton("addGamePitcher", "Add Pitcher", class = "btn-success btn-block"), hr(), selectInput("currentGamePitcher", "Currently Pitching:", choices = NULL), selectInput("gameBatterSide", "Batter Side:", choices = c("Right (Default)" = "R", "Left" = "L"), selected = "R"), hr(), div( style = "background: #f4f4f4; padding: 15px; border-radius: 10px;", h4("Game Stats"), verbatimTextOutput("gameStats") ) ), box( title = "Record Game Pitch", status = "success", solidHeader = TRUE, width = 8, div(class = "selection-display", uiOutput("gameSelectionDisplay")), h4("1. Pitch Call"), fluidRow( column(4, actionButton("game_pitch_fb", "Fastball", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_pitch_cb", "Curveball", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_pitch_ch", "Changeup", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) ), fluidRow( column(4, actionButton("game_pitch_sl", "Slider", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_pitch_ct", "Cutter", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_pitch_sn", "Sinker", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")) ), hr(), h4("2. Intended Location"), p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"), fluidRow( column(6, actionButton("game_int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")), column(6, actionButton("game_int_down", "DOWN", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) ), fluidRow( column(4, actionButton("game_int_away", "AWAY", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_int_middle", "MIDDLE", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), column(4, actionButton("game_int_in", "IN", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")) ), hr(), actionButton("recordGamePitch", "RECORD PITCH", class = "btn-success btn-block", style = "min-height: 80px; font-size: 24px;"), hr(), actionButton("clearGamePitch", "Clear Selection", class = "btn-warning btn-block") ) ), fluidRow( box( title = "Game Pitches", status = "info", solidHeader = TRUE, width = 12, DT::dataTableOutput("gamePitches") ) ) ), # LEADERBOARD TAB tabItem( tabName = "leaderboard", fluidRow( box( title = "Upload Previous Sessions", status = "info", solidHeader = TRUE, width = 12, fileInput("uploadCSV", "Upload CSV File(s) from Previous Bullpens:", multiple = TRUE, accept = c(".csv")), actionButton("clearUploads", "Clear Uploaded Data", class = "btn-warning") ) ), fluidRow( box( title = "Leaderboard Options", status = "primary", solidHeader = TRUE, width = 12, fluidRow( column( 2, numericInput("minPitches", "Minimum Pitches:", value = 10, min = 1) ), column( 10, checkboxGroupInput( "leaderboardCols", "Select Columns:", choices = c( "Pitcher" = "Pitcher", "Total Pitches" = "Total", "Overall Strike %" = "StrikeRate", "FB Strike %" = "FB_StrikeRate", "OS Strike %" = "OS_StrikeRate" ), selected = c("Pitcher", "FB_StrikeRate", "OS_StrikeRate", "StrikeRate"), inline = TRUE ) ) ) ) ), fluidRow( box( title = "Session Leaderboard", status = "warning", solidHeader = TRUE, width = 12, downloadButton("downloadLeaderboardCSV", "Download CSV", class = "btn-success", style = "margin-bottom: 15px; margin-right: 10px;"), downloadButton("downloadLeaderboardPNG", "Download PNG", class = "btn-primary", style = "margin-bottom: 15px;"), DT::dataTableOutput("leaderboard") ) ) ), # EXPORT TAB tabItem( tabName = "export", fluidRow( box( title = "Export Bullpen Data", status = "success", solidHeader = TRUE, width = 6, textInput("exportFilename", "Bullpen Session Name:", placeholder = "LastName1_LastName2_Bullpen"), downloadButton("downloadData", "Download Bullpen CSV", class = "btn-primary btn-lg") ), box( title = "Export Game Data", status = "info", solidHeader = TRUE, width = 6, textInput("exportGameFilename", "Game Name:", placeholder = "Game_vs_Opponent"), downloadButton("downloadGameData", "Download Game CSV", class = "btn-primary btn-lg") ) ) ) ) ) ) server <- function(input, output, session) { values <- reactiveValues( pitchers = character(0), pitchData = data.frame(), uploadedData = data.frame(), currentPitchType = NULL, currentIntendedVert = NULL, currentIntendedHorz = NULL, # Game tracking gamePitchers = character(0), gameData = data.frame(), gamePitchType = NULL, gameIntendedVert = NULL, gameIntendedHorz = NULL ) # BULLPEN SECTION output$selectionDisplay <- renderUI({ parts <- c() if (!is.null(values$currentPitchType)) { parts <- c(parts, paste("Type:", values$currentPitchType)) } if (!is.null(values$currentIntendedVert) || !is.null(values$currentIntendedHorz)) { loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ") parts <- c(parts, paste("Intended:", loc)) } if (length(parts) == 0) { HTML("Select pitch details...") } else { HTML(paste(parts, collapse = " | ")) } }) observeEvent(input$addPitcher, { req(input$pitcherName) name <- trimws(input$pitcherName) if (name != "" && !(name %in% values$pitchers)) { values$pitchers <- c(values$pitchers, name) updateSelectInput(session, "currentPitcher", choices = values$pitchers, selected = name) updateTextInput(session, "pitcherName", value = "") showNotification(paste("Added", name), type = "message") } }) output$currentStats <- renderText({ req(input$currentPitcher) data <- values$pitchData %>% filter(Pitcher == input$currentPitcher) if (nrow(data) == 0) return("No pitches yet") total <- nrow(data) strikes <- sum(data$Result == "Strike") strike_pct <- round(strikes / total * 100, 1) fb_data <- data %>% filter(PitchType %in% c("Fastball", "Sinker", "Cutter")) fb_total <- nrow(fb_data) fb_strikes <- sum(fb_data$Result == "Strike") fb_pct <- if (fb_total > 0) round(fb_strikes / fb_total * 100, 1) else 0 os_data <- data %>% filter(PitchType %in% c("Curveball", "Slider", "Changeup")) os_total <- nrow(os_data) os_strikes <- sum(os_data$Result == "Strike") os_pct <- if (os_total > 0) round(os_strikes / os_total * 100, 1) else 0 paste0( "Total Pitches: ", total, "\n", "==================\n", "Overall Strike%: ", strike_pct, "%\n", "FB Strike%: ", fb_pct, "% (", fb_total, " FB)\n", "OS Strike%: ", os_pct, "% (", os_total, " OS)" ) }) observeEvent(input$pitch_fb, { values$currentPitchType <- "Fastball" }) observeEvent(input$pitch_cb, { values$currentPitchType <- "Curveball" }) observeEvent(input$pitch_ch, { values$currentPitchType <- "Changeup" }) observeEvent(input$pitch_sl, { values$currentPitchType <- "Slider" }) observeEvent(input$pitch_ct, { values$currentPitchType <- "Cutter" }) observeEvent(input$pitch_sn, { values$currentPitchType <- "Sinker" }) observeEvent(input$int_up, { values$currentIntendedVert <- "Up" }) observeEvent(input$int_down, { values$currentIntendedVert <- "Down" }) observeEvent(input$int_away, { values$currentIntendedHorz <- "Away" }) observeEvent(input$int_middle, { values$currentIntendedHorz <- "Middle" }) observeEvent(input$int_in, { values$currentIntendedHorz <- "In" }) recordPitch <- function(result) { req(input$currentPitcher, values$currentPitchType) intended_loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ") if (intended_loc == "") intended_loc <- NA new_pitch <- data.frame( Pitcher = input$currentPitcher, PitchType = values$currentPitchType, BatterSide = input$batterSide, IntendedLocation = intended_loc, Result = result, Timestamp = Sys.time(), stringsAsFactors = FALSE ) values$pitchData <- rbind(values$pitchData, new_pitch) values$currentPitchType <- NULL values$currentIntendedVert <- NULL values$currentIntendedHorz <- NULL showNotification(paste(result, "recorded!"), type = "message", duration = 1) } observeEvent(input$result_strike, { recordPitch("Strike") }) observeEvent(input$result_ball, { recordPitch("Ball") }) observeEvent(input$clearPitch, { values$currentPitchType <- NULL values$currentIntendedVert <- NULL values$currentIntendedHorz <- NULL }) output$recentPitches <- DT::renderDataTable({ if (nrow(values$pitchData) == 0) { return(data.frame(Message = "No pitches recorded")) } recent <- tail(values$pitchData, 20) %>% arrange(desc(Timestamp)) %>% select(Pitcher, PitchType, BatterSide, IntendedLocation, Result) DT::datatable(recent, options = list(pageLength = 20, dom = 't'), rownames = FALSE) }) # GAME SECTION output$gameSelectionDisplay <- renderUI({ parts <- c() if (!is.null(values$gamePitchType)) { parts <- c(parts, paste("Call:", values$gamePitchType)) } if (!is.null(values$gameIntendedVert) || !is.null(values$gameIntendedHorz)) { loc <- paste(c(values$gameIntendedVert, values$gameIntendedHorz), collapse = " + ") parts <- c(parts, paste("Location:", loc)) } if (length(parts) == 0) { HTML("Select pitch call and location...") } else { HTML(paste(parts, collapse = " | ")) } }) observeEvent(input$addGamePitcher, { req(input$gamePitcherName) name <- trimws(input$gamePitcherName) if (name != "" && !(name %in% values$gamePitchers)) { values$gamePitchers <- c(values$gamePitchers, name) updateSelectInput(session, "currentGamePitcher", choices = values$gamePitchers, selected = name) updateTextInput(session, "gamePitcherName", value = "") showNotification(paste("Added", name, "to game"), type = "message") } }) output$gameStats <- renderText({ req(input$currentGamePitcher) data <- values$gameData %>% filter(Pitcher == input$currentGamePitcher) if (nrow(data) == 0) return("No pitches yet") total <- nrow(data) fb_data <- data %>% filter(PitchCall %in% c("Fastball", "Sinker", "Cutter")) fb_total <- nrow(fb_data) os_data <- data %>% filter(PitchCall %in% c("Curveball", "Slider", "Changeup")) os_total <- nrow(os_data) paste0( "Total Pitches: ", total, "\n", "==================\n", "Fastballs: ", fb_total, "\n", "Offspeed: ", os_total ) }) observeEvent(input$game_pitch_fb, { values$gamePitchType <- "Fastball" }) observeEvent(input$game_pitch_cb, { values$gamePitchType <- "Curveball" }) observeEvent(input$game_pitch_ch, { values$gamePitchType <- "Changeup" }) observeEvent(input$game_pitch_sl, { values$gamePitchType <- "Slider" }) observeEvent(input$game_pitch_ct, { values$gamePitchType <- "Cutter" }) observeEvent(input$game_pitch_sn, { values$gamePitchType <- "Sinker" }) observeEvent(input$game_int_up, { values$gameIntendedVert <- "Up" }) observeEvent(input$game_int_down, { values$gameIntendedVert <- "Down" }) observeEvent(input$game_int_away, { values$gameIntendedHorz <- "Away" }) observeEvent(input$game_int_middle, { values$gameIntendedHorz <- "Middle" }) observeEvent(input$game_int_in, { values$gameIntendedHorz <- "In" }) observeEvent(input$recordGamePitch, { req(input$currentGamePitcher, values$gamePitchType) intended_loc <- paste(c(values$gameIntendedVert, values$gameIntendedHorz), collapse = " + ") if (intended_loc == "") intended_loc <- NA new_pitch <- data.frame( Pitcher = input$currentGamePitcher, PitchCall = values$gamePitchType, BatterSide = input$gameBatterSide, IntendedLocation = intended_loc, Timestamp = Sys.time(), stringsAsFactors = FALSE ) values$gameData <- rbind(values$gameData, new_pitch) values$gamePitchType <- NULL values$gameIntendedVert <- NULL values$gameIntendedHorz <- NULL showNotification("Pitch recorded!", type = "message", duration = 1) }) observeEvent(input$clearGamePitch, { values$gamePitchType <- NULL values$gameIntendedVert <- NULL values$gameIntendedHorz <- NULL }) output$gamePitches <- DT::renderDataTable({ if (nrow(values$gameData) == 0) { return(data.frame(Message = "No pitches recorded")) } recent <- tail(values$gameData, 50) %>% arrange(desc(Timestamp)) %>% select(Pitcher, PitchCall, BatterSide, IntendedLocation) DT::datatable(recent, options = list(pageLength = 50, dom = 't'), rownames = FALSE) }) # LEADERBOARD SECTION observeEvent(input$uploadCSV, { req(input$uploadCSV) tryCatch({ uploaded_list <- lapply(input$uploadCSV$datapath, function(path) { read_csv(path, show_col_types = FALSE) }) new_data <- bind_rows(uploaded_list) values$uploadedData <- bind_rows(values$uploadedData, new_data) showNotification(paste("Uploaded", nrow(new_data), "pitches from", length(uploaded_list), "file(s)"), type = "message") }, error = function(e) { showNotification(paste("Error uploading files:", e$message), type = "error") }) }) observeEvent(input$clearUploads, { values$uploadedData <- data.frame() showNotification("Cleared uploaded data", type = "warning") }) generate_leaderboard <- function() { all_data <- bind_rows(values$pitchData, values$uploadedData) if (nrow(all_data) == 0) { return(NULL) } leaderboard <- all_data %>% group_by(Pitcher) %>% summarise( Total = n(), Strikes = sum(Result == "Strike"), StrikeRate = round(Strikes / Total * 100, 1), FB_Total = sum(PitchType %in% c("Fastball", "Sinker", "Cutter")), FB_Strikes = sum(Result == "Strike" & PitchType %in% c("Fastball", "Sinker", "Cutter")), FB_StrikeRate = if_else(FB_Total > 0, round(FB_Strikes / FB_Total * 100, 1), NA_real_), OS_Total = sum(PitchType %in% c("Curveball", "Slider", "Changeup")), OS_Strikes = sum(Result == "Strike" & PitchType %in% c("Curveball", "Slider", "Changeup")), OS_StrikeRate = if_else(OS_Total > 0, round(OS_Strikes / OS_Total * 100, 1), NA_real_), .groups = 'drop' ) %>% filter(Total >= input$minPitches) return(leaderboard) } prepare_leaderboard_display <- function(leaderboard) { if (is.null(leaderboard)) return(NULL) leaderboard_display <- leaderboard if ("Pitcher" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(PLAYER = Pitcher) if ("FB_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`FB STRIKE%` = FB_StrikeRate) if ("OS_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OS STRIKE%` = OS_StrikeRate) if ("StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OVR STRIKE%` = StrikeRate) if ("Total" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`TOTAL PITCHES` = Total) if (!is.null(input$leaderboardCols) && length(input$leaderboardCols) > 0) { cols_to_keep <- input$leaderboardCols col_mapping <- c( "Pitcher" = "PLAYER", "Total" = "TOTAL PITCHES", "StrikeRate" = "OVR STRIKE%", "FB_StrikeRate" = "FB STRIKE%", "OS_StrikeRate" = "OS STRIKE%" ) display_cols <- col_mapping[cols_to_keep] display_cols <- display_cols[!is.na(display_cols)] leaderboard_display <- leaderboard_display %>% select(all_of(display_cols)) } return(leaderboard_display) } output$leaderboard <- DT::renderDataTable({ leaderboard <- generate_leaderboard() if (is.null(leaderboard)) { return(data.frame(Message = "No data yet")) } leaderboard_display <- prepare_leaderboard_display(leaderboard) dt <- DT::datatable( leaderboard_display, options = list( pageLength = 25, scrollX = TRUE, ordering = TRUE, dom = 't', columnDefs = list( list(className = 'dt-center', targets = '_all') ) ), rownames = FALSE, class = 'cell-border stripe' ) %>% formatStyle( columns = colnames(leaderboard_display), fontSize = '18px', fontWeight = 'bold', color = 'black', border = '2px solid black' ) %>% formatStyle( columns = 1, fontWeight = 'bold', border = '2px solid black' ) pct_cols <- intersect( c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%"), colnames(leaderboard_display) ) for (col in pct_cols) { dt <- dt %>% formatStyle( col, backgroundColor = styleInterval( cuts = c(30, 40, 50, 55, 60, 65, 70), values = c('#FF6B6B', '#FF8C8C', '#FFD700', '#FFED4E', '#90EE90', '#7CCD7C', '#32CD32', '#228B22') ), color = 'black', fontWeight = 'bold', border = '2px solid black' ) } dt }) output$downloadLeaderboardCSV <- downloadHandler( filename = function() { paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".csv") }, content = function(file) { leaderboard <- generate_leaderboard() if (!is.null(leaderboard)) { write_csv(leaderboard, file) } else { write_csv(data.frame(Message = "No data"), file) } } ) output$downloadLeaderboardPNG <- downloadHandler( filename = function() { paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".png") }, content = function(file) { leaderboard <- generate_leaderboard() if (is.null(leaderboard)) { showNotification("No data to export", type = "error") return() } leaderboard_display <- prepare_leaderboard_display(leaderboard) if (is.null(leaderboard_display)) { showNotification("Error preparing leaderboard data", type = "error") return() } dt <- DT::datatable( leaderboard_display, options = list( pageLength = nrow(leaderboard_display), dom = 't', ordering = FALSE, columnDefs = list( list(className = 'dt-center', targets = '_all') ) ), rownames = FALSE, class = 'cell-border stripe' ) %>% formatStyle( columns = colnames(leaderboard_display), fontSize = '18px', fontWeight = 'bold', color = 'black', border = '2px solid black' ) %>% formatStyle( columns = 1, fontWeight = 'bold', border = '2px solid black' ) pct_cols <- intersect( c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%"), colnames(leaderboard_display) ) for (col in pct_cols) { dt <- dt %>% formatStyle( col, backgroundColor = styleInterval( cuts = c(30, 40, 50, 55, 60, 65, 70), values = c('#FF6B6B', '#FF8C8C', '#FFD700', '#FFED4E', '#90EE90', '#7CCD7C', '#32CD32', '#228B22') ), color = 'black', fontWeight = 'bold', border = '2px solid black' ) } temp_html <- tempfile(fileext = ".html") htmlwidgets::saveWidget(dt, temp_html, selfcontained = TRUE) tryCatch({ webshot2::webshot(temp_html, file = file, vwidth = 1400, vheight = 1000, delay = 0.5) showNotification("Leaderboard PNG downloaded successfully!", type = "message") }, error = function(e) { showNotification(paste("Error creating PNG:", e$message), type = "error") }) unlink(temp_html) } ) # EXPORT HANDLERS output$downloadData <- downloadHandler( filename = function() { base_name <- if (!is.null(input$exportFilename) && input$exportFilename != "") { gsub(" ", "_", trimws(input$exportFilename)) } else { "Bullpen_Session" } date_str <- format(Sys.Date(), "%m%d") paste0(base_name, "_", date_str, ".csv") }, content = function(file) { write_csv(values$pitchData, file) } ) output$downloadGameData <- downloadHandler( filename = function() { base_name <- if (!is.null(input$exportGameFilename) && input$exportGameFilename != "") { gsub(" ", "_", trimws(input$exportGameFilename)) } else { "Game_Data" } date_str <- format(Sys.Date(), "%m%d") paste0(base_name, "_", date_str, ".csv") }, content = function(file) { write_csv(values$gameData, file) } ) } shinyApp(ui = ui, server = server)