igroffman commited on
Commit
fe2f610
·
verified ·
1 Parent(s): 268801b

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +248 -176
app.R CHANGED
@@ -8,10 +8,11 @@ library(webshot2)
8
  library(htmlwidgets)
9
 
10
  ui <- dashboardPage(
11
- dashboardHeader(title = "Bullpen Tracker"),
12
  dashboardSidebar(
13
  sidebarMenu(
14
  menuItem("Bullpen Session", tabName = "bullpen", icon = icon("baseball")),
 
15
  menuItem("Leaderboard", tabName = "leaderboard", icon = icon("chart-bar")),
16
  menuItem("Export Data", tabName = "export", icon = icon("download"))
17
  )
@@ -52,6 +53,7 @@ ui <- dashboardPage(
52
  ),
53
 
54
  tabItems(
 
55
  tabItem(
56
  tabName = "bullpen",
57
  fluidRow(
@@ -95,7 +97,7 @@ ui <- dashboardPage(
95
  ),
96
 
97
  hr(),
98
- h4("2. Intended Location (Optional)"),
99
  p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"),
100
  fluidRow(
101
  column(6, actionButton("int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")),
@@ -108,41 +110,12 @@ ui <- dashboardPage(
108
  ),
109
 
110
  hr(),
111
- h4("3. Actual Zone"),
112
- div(
113
- style = "text-align: center; max-width: 500px; margin: 0 auto;",
114
- actionButton("zone_12", "12", class = "border-zone-btn btn-outline-secondary", style = "width: 66%; margin: 5px auto;"),
115
- div(
116
- style = "display: flex; gap: 5px; justify-content: center; align-items: center;",
117
- actionButton("zone_14", "14", class = "border-zone-btn btn-outline-secondary", style = "width: 60px; height: 280px;"),
118
- div(
119
- class = "zone-grid",
120
- actionButton("zone_3", "3", class = "zone-btn btn-primary"),
121
- actionButton("zone_2", "2", class = "zone-btn btn-primary"),
122
- actionButton("zone_1", "1", class = "zone-btn btn-primary"),
123
- actionButton("zone_6", "6", class = "zone-btn btn-primary"),
124
- actionButton("zone_5", "5", class = "zone-btn btn-success"),
125
- actionButton("zone_4", "4", class = "zone-btn btn-primary"),
126
- actionButton("zone_9", "9", class = "zone-btn btn-info"),
127
- actionButton("zone_8", "8", class = "zone-btn btn-info"),
128
- actionButton("zone_7", "7", class = "zone-btn btn-info")
129
- ),
130
- actionButton("zone_11", "11", class = "border-zone-btn btn-outline-secondary", style = "width: 60px; height: 280px;")
131
- ),
132
- actionButton("zone_13", "13", class = "border-zone-btn btn-outline-secondary", style = "width: 66%; margin: 5px auto;")
133
- ),
134
-
135
- hr(),
136
- h4("4. Result"),
137
  fluidRow(
138
  column(6, actionButton("result_strike", "STRIKE", class = "btn-success", style = "width: 100%; min-height: 80px; font-size: 24px;")),
139
  column(6, actionButton("result_ball", "BALL", class = "btn-danger", style = "width: 100%; min-height: 80px; font-size: 24px;"))
140
  ),
141
 
142
- hr(),
143
- h4("5. Velocity (Required)"),
144
- numericInput("velo", "MPH:", value = NULL, min = 40, max = 110, width = "200px"),
145
-
146
  hr(),
147
  actionButton("clearPitch", "Clear Selection", class = "btn-warning btn-block")
148
  )
@@ -159,6 +132,81 @@ ui <- dashboardPage(
159
  )
160
  ),
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  tabItem(
163
  tabName = "leaderboard",
164
  fluidRow(
@@ -194,12 +242,7 @@ ui <- dashboardPage(
194
  "Total Pitches" = "Total",
195
  "Overall Strike %" = "StrikeRate",
196
  "FB Strike %" = "FB_StrikeRate",
197
- "OS Strike %" = "OS_StrikeRate",
198
- "Avg FB Velo" = "AvgFBVelo",
199
- "Max FB Velo" = "MaxFBVelo",
200
- "Avg Velo" = "AvgVelo",
201
- "Hit Spot %" = "HitSpotRate",
202
- "In Zone %" = "InZoneRate"
203
  ),
204
  selected = c("Pitcher", "FB_StrikeRate", "OS_StrikeRate", "StrikeRate"),
205
  inline = TRUE
@@ -221,17 +264,27 @@ ui <- dashboardPage(
221
  )
222
  ),
223
 
 
224
  tabItem(
225
  tabName = "export",
226
  fluidRow(
227
  box(
228
- title = "Export Session Data",
229
  status = "success",
230
  solidHeader = TRUE,
231
- width = 12,
232
- textInput("exportFilename", "Session Name (e.g., Horn_Doran_Bullpen):",
233
  placeholder = "LastName1_LastName2_Bullpen"),
234
- downloadButton("downloadData", "Download CSV", class = "btn-primary btn-lg")
 
 
 
 
 
 
 
 
 
235
  )
236
  )
237
  )
@@ -248,9 +301,15 @@ server <- function(input, output, session) {
248
  currentPitchType = NULL,
249
  currentIntendedVert = NULL,
250
  currentIntendedHorz = NULL,
251
- currentZone = NULL
 
 
 
 
 
252
  )
253
 
 
254
  output$selectionDisplay <- renderUI({
255
  parts <- c()
256
  if (!is.null(values$currentPitchType)) {
@@ -260,12 +319,6 @@ server <- function(input, output, session) {
260
  loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ")
261
  parts <- c(parts, paste("Intended:", loc))
262
  }
263
- if (!is.null(values$currentZone)) {
264
- parts <- c(parts, paste("Zone:", values$currentZone))
265
- }
266
- if (!is.null(input$velo) && !is.na(input$velo)) {
267
- parts <- c(parts, paste(input$velo, "MPH"))
268
- }
269
 
270
  if (length(parts) == 0) {
271
  HTML("<span style='color: #999;'>Select pitch details...</span>")
@@ -304,17 +357,12 @@ server <- function(input, output, session) {
304
  os_strikes <- sum(os_data$Result == "Strike")
305
  os_pct <- if (os_total > 0) round(os_strikes / os_total * 100, 1) else 0
306
 
307
- hit_spot <- sum(data$HitSpot, na.rm = TRUE)
308
- hit_spot_pct <- round(hit_spot / total * 100, 1)
309
-
310
  paste0(
311
  "Total Pitches: ", total, "\n",
312
  "==================\n",
313
  "Overall Strike%: ", strike_pct, "%\n",
314
  "FB Strike%: ", fb_pct, "% (", fb_total, " FB)\n",
315
- "OS Strike%: ", os_pct, "% (", os_total, " OS)\n",
316
- "==================\n",
317
- "Hit Spot%: ", hit_spot_pct, "% (", hit_spot, "/", total, ")"
318
  )
319
  })
320
 
@@ -332,85 +380,18 @@ server <- function(input, output, session) {
332
  observeEvent(input$int_middle, { values$currentIntendedHorz <- "Middle" })
333
  observeEvent(input$int_in, { values$currentIntendedHorz <- "In" })
334
 
335
- observeEvent(input$zone_1, { values$currentZone <- 1 })
336
- observeEvent(input$zone_2, { values$currentZone <- 2 })
337
- observeEvent(input$zone_3, { values$currentZone <- 3 })
338
- observeEvent(input$zone_4, { values$currentZone <- 4 })
339
- observeEvent(input$zone_5, { values$currentZone <- 5 })
340
- observeEvent(input$zone_6, { values$currentZone <- 6 })
341
- observeEvent(input$zone_7, { values$currentZone <- 7 })
342
- observeEvent(input$zone_8, { values$currentZone <- 8 })
343
- observeEvent(input$zone_9, { values$currentZone <- 9 })
344
- observeEvent(input$zone_11, { values$currentZone <- 11 })
345
- observeEvent(input$zone_12, { values$currentZone <- 12 })
346
- observeEvent(input$zone_13, { values$currentZone <- 13 })
347
- observeEvent(input$zone_14, { values$currentZone <- 14 })
348
-
349
  recordPitch <- function(result) {
350
- req(input$currentPitcher, values$currentPitchType, values$currentZone)
351
-
352
- if (is.null(input$velo) || is.na(input$velo)) {
353
- showNotification("Please enter velocity!", type = "error")
354
- return()
355
- }
356
-
357
- batter_side <- input$batterSide
358
- zone <- values$currentZone
359
-
360
- hit_spot <- NA
361
-
362
- if (!is.null(values$currentIntendedVert) || !is.null(values$currentIntendedHorz)) {
363
- vert_match <- FALSE
364
- horz_match <- FALSE
365
-
366
- if (!is.null(values$currentIntendedVert)) {
367
- if (values$currentIntendedVert == "Up" && zone %in% c(1, 2, 3, 12)) {
368
- vert_match <- TRUE
369
- } else if (values$currentIntendedVert == "Down" && zone %in% c(7, 8, 9, 13)) {
370
- vert_match <- TRUE
371
- }
372
- } else {
373
- vert_match <- TRUE
374
- }
375
-
376
- if (!is.null(values$currentIntendedHorz)) {
377
- if (batter_side == "R") {
378
- if (values$currentIntendedHorz == "Away" && zone %in% c(3, 6, 9, 14)) {
379
- horz_match <- TRUE
380
- } else if (values$currentIntendedHorz == "In" && zone %in% c(1, 4, 7, 11)) {
381
- horz_match <- TRUE
382
- } else if (values$currentIntendedHorz == "Middle" && zone %in% c(2, 5, 8)) {
383
- horz_match <- TRUE
384
- }
385
- } else {
386
- if (values$currentIntendedHorz == "Away" && zone %in% c(1, 4, 7, 11)) {
387
- horz_match <- TRUE
388
- } else if (values$currentIntendedHorz == "In" && zone %in% c(3, 6, 9, 14)) {
389
- horz_match <- TRUE
390
- } else if (values$currentIntendedHorz == "Middle" && zone %in% c(2, 5, 8)) {
391
- horz_match <- TRUE
392
- }
393
- }
394
- } else {
395
- horz_match <- TRUE
396
- }
397
-
398
- hit_spot <- vert_match && horz_match
399
- }
400
 
401
- in_zone <- zone <= 9
 
402
 
403
  new_pitch <- data.frame(
404
  Pitcher = input$currentPitcher,
405
  PitchType = values$currentPitchType,
406
- Velocity = input$velo,
407
- BatterSide = batter_side,
408
- IntendedVert = if (!is.null(values$currentIntendedVert)) values$currentIntendedVert else NA,
409
- IntendedHorz = if (!is.null(values$currentIntendedHorz)) values$currentIntendedHorz else NA,
410
- ActualZone = values$currentZone,
411
  Result = result,
412
- HitSpot = hit_spot,
413
- InZone = in_zone,
414
  Timestamp = Sys.time(),
415
  stringsAsFactors = FALSE
416
  )
@@ -420,8 +401,6 @@ server <- function(input, output, session) {
420
  values$currentPitchType <- NULL
421
  values$currentIntendedVert <- NULL
422
  values$currentIntendedHorz <- NULL
423
- values$currentZone <- NULL
424
- updateNumericInput(session, "velo", value = NA)
425
 
426
  showNotification(paste(result, "recorded!"), type = "message", duration = 1)
427
  }
@@ -433,10 +412,127 @@ server <- function(input, output, session) {
433
  values$currentPitchType <- NULL
434
  values$currentIntendedVert <- NULL
435
  values$currentIntendedHorz <- NULL
436
- values$currentZone <- NULL
437
- updateNumericInput(session, "velo", value = NA)
438
  })
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  observeEvent(input$uploadCSV, {
441
  req(input$uploadCSV)
442
 
@@ -460,20 +556,6 @@ server <- function(input, output, session) {
460
  showNotification("Cleared uploaded data", type = "warning")
461
  })
462
 
463
- output$recentPitches <- DT::renderDataTable({
464
- if (nrow(values$pitchData) == 0) {
465
- return(data.frame(Message = "No pitches recorded"))
466
- }
467
-
468
- recent <- tail(values$pitchData, 20) %>%
469
- arrange(desc(Timestamp)) %>%
470
- select(Pitcher, PitchType, Velocity, BatterSide, IntendedVert, IntendedHorz, ActualZone, Result, HitSpot)
471
-
472
- DT::datatable(recent, options = list(pageLength = 20, dom = 't'), rownames = FALSE) %>%
473
- formatStyle('HitSpot', backgroundColor = styleEqual(c(TRUE, FALSE), c('#90EE90', '#FFB6C1')))
474
- })
475
-
476
- # Function to generate leaderboard data
477
  generate_leaderboard <- function() {
478
  all_data <- bind_rows(values$pitchData, values$uploadedData)
479
 
@@ -493,11 +575,6 @@ server <- function(input, output, session) {
493
  OS_Total = sum(PitchType %in% c("Curveball", "Slider", "Changeup")),
494
  OS_Strikes = sum(Result == "Strike" & PitchType %in% c("Curveball", "Slider", "Changeup")),
495
  OS_StrikeRate = if_else(OS_Total > 0, round(OS_Strikes / OS_Total * 100, 1), NA_real_),
496
- AvgFBVelo = round(mean(Velocity[PitchType %in% c("Fastball", "Sinker", "Cutter")], na.rm = TRUE), 1),
497
- MaxFBVelo = suppressWarnings(max(Velocity[PitchType %in% c("Fastball", "Sinker", "Cutter")], na.rm = TRUE)),
498
- AvgVelo = round(mean(Velocity, na.rm = TRUE), 1),
499
- HitSpotRate = round(mean(HitSpot, na.rm = TRUE) * 100, 1),
500
- InZoneRate = round(mean(InZone) * 100, 1),
501
  .groups = 'drop'
502
  ) %>%
503
  filter(Total >= input$minPitches)
@@ -505,24 +582,16 @@ server <- function(input, output, session) {
505
  return(leaderboard)
506
  }
507
 
508
- # Function to prepare leaderboard display (used by both render and download)
509
  prepare_leaderboard_display <- function(leaderboard) {
510
  if (is.null(leaderboard)) return(NULL)
511
 
512
- # Rename columns for display
513
  leaderboard_display <- leaderboard
514
  if ("Pitcher" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(PLAYER = Pitcher)
515
  if ("FB_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`FB STRIKE%` = FB_StrikeRate)
516
  if ("OS_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OS STRIKE%` = OS_StrikeRate)
517
  if ("StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OVR STRIKE%` = StrikeRate)
518
- if ("AvgFBVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`AVG FB VELO` = AvgFBVelo)
519
- if ("MaxFBVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`MAX FB VELO` = MaxFBVelo)
520
- if ("AvgVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`AVG VELO` = AvgVelo)
521
- if ("HitSpotRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`HIT SPOT%` = HitSpotRate)
522
- if ("InZoneRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`IN ZONE%` = InZoneRate)
523
  if ("Total" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`TOTAL PITCHES` = Total)
524
 
525
- # Select only chosen columns
526
  if (!is.null(input$leaderboardCols) && length(input$leaderboardCols) > 0) {
527
  cols_to_keep <- input$leaderboardCols
528
 
@@ -531,12 +600,7 @@ server <- function(input, output, session) {
531
  "Total" = "TOTAL PITCHES",
532
  "StrikeRate" = "OVR STRIKE%",
533
  "FB_StrikeRate" = "FB STRIKE%",
534
- "OS_StrikeRate" = "OS STRIKE%",
535
- "AvgFBVelo" = "AVG FB VELO",
536
- "MaxFBVelo" = "MAX FB VELO",
537
- "AvgVelo" = "AVG VELO",
538
- "HitSpotRate" = "HIT SPOT%",
539
- "InZoneRate" = "IN ZONE%"
540
  )
541
 
542
  display_cols <- col_mapping[cols_to_keep]
@@ -583,9 +647,8 @@ server <- function(input, output, session) {
583
  border = '2px solid black'
584
  )
585
 
586
- # Apply conditional formatting to percentage columns
587
  pct_cols <- intersect(
588
- c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%", "HIT SPOT%", "IN ZONE%"),
589
  colnames(leaderboard_display)
590
  )
591
 
@@ -606,7 +669,6 @@ server <- function(input, output, session) {
606
  dt
607
  })
608
 
609
- # Download leaderboard as CSV
610
  output$downloadLeaderboardCSV <- downloadHandler(
611
  filename = function() {
612
  paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".csv")
@@ -622,9 +684,9 @@ server <- function(input, output, session) {
622
  }
623
  )
624
 
625
- # Download leaderboard as PNG
626
  output$downloadLeaderboardPNG <- downloadHandler(
627
- filename = function() {paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".png")
 
628
  },
629
  content = function(file) {
630
  leaderboard <- generate_leaderboard()
@@ -634,7 +696,6 @@ server <- function(input, output, session) {
634
  return()
635
  }
636
 
637
- # Prepare display version using the shared function
638
  leaderboard_display <- prepare_leaderboard_display(leaderboard)
639
 
640
  if (is.null(leaderboard_display)) {
@@ -642,7 +703,6 @@ server <- function(input, output, session) {
642
  return()
643
  }
644
 
645
- # Create datatable widget with conditional formatting
646
  dt <- DT::datatable(
647
  leaderboard_display,
648
  options = list(
@@ -669,9 +729,8 @@ server <- function(input, output, session) {
669
  border = '2px solid black'
670
  )
671
 
672
- # Apply conditional formatting to percentage columns
673
  pct_cols <- intersect(
674
- c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%", "HIT SPOT%", "IN ZONE%"),
675
  colnames(leaderboard_display)
676
  )
677
 
@@ -689,11 +748,9 @@ server <- function(input, output, session) {
689
  )
690
  }
691
 
692
- # Save as HTML temporarily
693
  temp_html <- tempfile(fileext = ".html")
694
  htmlwidgets::saveWidget(dt, temp_html, selfcontained = TRUE)
695
 
696
- # Convert to PNG using webshot2
697
  tryCatch({
698
  webshot2::webshot(temp_html, file = file, vwidth = 1400, vheight = 1000, delay = 0.5)
699
  showNotification("Leaderboard PNG downloaded successfully!", type = "message")
@@ -701,11 +758,11 @@ server <- function(input, output, session) {
701
  showNotification(paste("Error creating PNG:", e$message), type = "error")
702
  })
703
 
704
- # Clean up temp file
705
  unlink(temp_html)
706
  }
707
  )
708
 
 
709
  output$downloadData <- downloadHandler(
710
  filename = function() {
711
  base_name <- if (!is.null(input$exportFilename) && input$exportFilename != "") {
@@ -720,6 +777,21 @@ server <- function(input, output, session) {
720
  write_csv(values$pitchData, file)
721
  }
722
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  }
724
 
725
  shinyApp(ui = ui, server = server)
 
8
  library(htmlwidgets)
9
 
10
  ui <- dashboardPage(
11
+ dashboardHeader(title = "Pitching Tracker"),
12
  dashboardSidebar(
13
  sidebarMenu(
14
  menuItem("Bullpen Session", tabName = "bullpen", icon = icon("baseball")),
15
+ menuItem("Game Tracking", tabName = "game", icon = icon("gamepad")),
16
  menuItem("Leaderboard", tabName = "leaderboard", icon = icon("chart-bar")),
17
  menuItem("Export Data", tabName = "export", icon = icon("download"))
18
  )
 
53
  ),
54
 
55
  tabItems(
56
+ # BULLPEN TAB - SIMPLIFIED
57
  tabItem(
58
  tabName = "bullpen",
59
  fluidRow(
 
97
  ),
98
 
99
  hr(),
100
+ h4("2. Intended Location"),
101
  p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"),
102
  fluidRow(
103
  column(6, actionButton("int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")),
 
110
  ),
111
 
112
  hr(),
113
+ h4("3. Result"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  fluidRow(
115
  column(6, actionButton("result_strike", "STRIKE", class = "btn-success", style = "width: 100%; min-height: 80px; font-size: 24px;")),
116
  column(6, actionButton("result_ball", "BALL", class = "btn-danger", style = "width: 100%; min-height: 80px; font-size: 24px;"))
117
  ),
118
 
 
 
 
 
119
  hr(),
120
  actionButton("clearPitch", "Clear Selection", class = "btn-warning btn-block")
121
  )
 
132
  )
133
  ),
134
 
135
+ # GAME TAB - NEW
136
+ tabItem(
137
+ tabName = "game",
138
+ fluidRow(
139
+ box(
140
+ title = "Game Setup",
141
+ status = "primary",
142
+ solidHeader = TRUE,
143
+ width = 4,
144
+ textInput("gamePitcherName", "Pitcher Name:", placeholder = "Enter name"),
145
+ actionButton("addGamePitcher", "Add Pitcher", class = "btn-success btn-block"),
146
+ hr(),
147
+ selectInput("currentGamePitcher", "Currently Pitching:", choices = NULL),
148
+ selectInput("gameBatterSide", "Batter Side:",
149
+ choices = c("Right (Default)" = "R", "Left" = "L"),
150
+ selected = "R"),
151
+ hr(),
152
+ div(
153
+ style = "background: #f4f4f4; padding: 15px; border-radius: 10px;",
154
+ h4("Game Stats"),
155
+ verbatimTextOutput("gameStats")
156
+ )
157
+ ),
158
+
159
+ box(
160
+ title = "Record Game Pitch",
161
+ status = "success",
162
+ solidHeader = TRUE,
163
+ width = 8,
164
+ div(class = "selection-display", uiOutput("gameSelectionDisplay")),
165
+
166
+ h4("1. Pitch Call"),
167
+ fluidRow(
168
+ column(4, actionButton("game_pitch_fb", "Fastball", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")),
169
+ column(4, actionButton("game_pitch_cb", "Curveball", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")),
170
+ column(4, actionButton("game_pitch_ch", "Changeup", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;"))
171
+ ),
172
+ fluidRow(
173
+ column(4, actionButton("game_pitch_sl", "Slider", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")),
174
+ column(4, actionButton("game_pitch_ct", "Cutter", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")),
175
+ column(4, actionButton("game_pitch_sn", "Sinker", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;"))
176
+ ),
177
+
178
+ hr(),
179
+ h4("2. Intended Location"),
180
+ p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"),
181
+ fluidRow(
182
+ column(6, actionButton("game_int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")),
183
+ column(6, actionButton("game_int_down", "DOWN", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;"))
184
+ ),
185
+ fluidRow(
186
+ column(4, actionButton("game_int_away", "AWAY", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")),
187
+ column(4, actionButton("game_int_middle", "MIDDLE", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")),
188
+ column(4, actionButton("game_int_in", "IN", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;"))
189
+ ),
190
+
191
+ hr(),
192
+ actionButton("recordGamePitch", "RECORD PITCH", class = "btn-success btn-block", style = "min-height: 80px; font-size: 24px;"),
193
+ hr(),
194
+ actionButton("clearGamePitch", "Clear Selection", class = "btn-warning btn-block")
195
+ )
196
+ ),
197
+
198
+ fluidRow(
199
+ box(
200
+ title = "Game Pitches",
201
+ status = "info",
202
+ solidHeader = TRUE,
203
+ width = 12,
204
+ DT::dataTableOutput("gamePitches")
205
+ )
206
+ )
207
+ ),
208
+
209
+ # LEADERBOARD TAB
210
  tabItem(
211
  tabName = "leaderboard",
212
  fluidRow(
 
242
  "Total Pitches" = "Total",
243
  "Overall Strike %" = "StrikeRate",
244
  "FB Strike %" = "FB_StrikeRate",
245
+ "OS Strike %" = "OS_StrikeRate"
 
 
 
 
 
246
  ),
247
  selected = c("Pitcher", "FB_StrikeRate", "OS_StrikeRate", "StrikeRate"),
248
  inline = TRUE
 
264
  )
265
  ),
266
 
267
+ # EXPORT TAB
268
  tabItem(
269
  tabName = "export",
270
  fluidRow(
271
  box(
272
+ title = "Export Bullpen Data",
273
  status = "success",
274
  solidHeader = TRUE,
275
+ width = 6,
276
+ textInput("exportFilename", "Bullpen Session Name:",
277
  placeholder = "LastName1_LastName2_Bullpen"),
278
+ downloadButton("downloadData", "Download Bullpen CSV", class = "btn-primary btn-lg")
279
+ ),
280
+ box(
281
+ title = "Export Game Data",
282
+ status = "info",
283
+ solidHeader = TRUE,
284
+ width = 6,
285
+ textInput("exportGameFilename", "Game Name:",
286
+ placeholder = "Game_vs_Opponent"),
287
+ downloadButton("downloadGameData", "Download Game CSV", class = "btn-primary btn-lg")
288
  )
289
  )
290
  )
 
301
  currentPitchType = NULL,
302
  currentIntendedVert = NULL,
303
  currentIntendedHorz = NULL,
304
+ # Game tracking
305
+ gamePitchers = character(0),
306
+ gameData = data.frame(),
307
+ gamePitchType = NULL,
308
+ gameIntendedVert = NULL,
309
+ gameIntendedHorz = NULL
310
  )
311
 
312
+ # BULLPEN SECTION
313
  output$selectionDisplay <- renderUI({
314
  parts <- c()
315
  if (!is.null(values$currentPitchType)) {
 
319
  loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ")
320
  parts <- c(parts, paste("Intended:", loc))
321
  }
 
 
 
 
 
 
322
 
323
  if (length(parts) == 0) {
324
  HTML("<span style='color: #999;'>Select pitch details...</span>")
 
357
  os_strikes <- sum(os_data$Result == "Strike")
358
  os_pct <- if (os_total > 0) round(os_strikes / os_total * 100, 1) else 0
359
 
 
 
 
360
  paste0(
361
  "Total Pitches: ", total, "\n",
362
  "==================\n",
363
  "Overall Strike%: ", strike_pct, "%\n",
364
  "FB Strike%: ", fb_pct, "% (", fb_total, " FB)\n",
365
+ "OS Strike%: ", os_pct, "% (", os_total, " OS)"
 
 
366
  )
367
  })
368
 
 
380
  observeEvent(input$int_middle, { values$currentIntendedHorz <- "Middle" })
381
  observeEvent(input$int_in, { values$currentIntendedHorz <- "In" })
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  recordPitch <- function(result) {
384
+ req(input$currentPitcher, values$currentPitchType)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
+ intended_loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ")
387
+ if (intended_loc == "") intended_loc <- NA
388
 
389
  new_pitch <- data.frame(
390
  Pitcher = input$currentPitcher,
391
  PitchType = values$currentPitchType,
392
+ BatterSide = input$batterSide,
393
+ IntendedLocation = intended_loc,
 
 
 
394
  Result = result,
 
 
395
  Timestamp = Sys.time(),
396
  stringsAsFactors = FALSE
397
  )
 
401
  values$currentPitchType <- NULL
402
  values$currentIntendedVert <- NULL
403
  values$currentIntendedHorz <- NULL
 
 
404
 
405
  showNotification(paste(result, "recorded!"), type = "message", duration = 1)
406
  }
 
412
  values$currentPitchType <- NULL
413
  values$currentIntendedVert <- NULL
414
  values$currentIntendedHorz <- NULL
 
 
415
  })
416
 
417
+ output$recentPitches <- DT::renderDataTable({
418
+ if (nrow(values$pitchData) == 0) {
419
+ return(data.frame(Message = "No pitches recorded"))
420
+ }
421
+
422
+ recent <- tail(values$pitchData, 20) %>%
423
+ arrange(desc(Timestamp)) %>%
424
+ select(Pitcher, PitchType, BatterSide, IntendedLocation, Result)
425
+
426
+ DT::datatable(recent, options = list(pageLength = 20, dom = 't'), rownames = FALSE)
427
+ })
428
+
429
+ # GAME SECTION
430
+ output$gameSelectionDisplay <- renderUI({
431
+ parts <- c()
432
+ if (!is.null(values$gamePitchType)) {
433
+ parts <- c(parts, paste("Call:", values$gamePitchType))
434
+ }
435
+ if (!is.null(values$gameIntendedVert) || !is.null(values$gameIntendedHorz)) {
436
+ loc <- paste(c(values$gameIntendedVert, values$gameIntendedHorz), collapse = " + ")
437
+ parts <- c(parts, paste("Location:", loc))
438
+ }
439
+
440
+ if (length(parts) == 0) {
441
+ HTML("<span style='color: #999;'>Select pitch call and location...</span>")
442
+ } else {
443
+ HTML(paste(parts, collapse = " | "))
444
+ }
445
+ })
446
+
447
+ observeEvent(input$addGamePitcher, {
448
+ req(input$gamePitcherName)
449
+ name <- trimws(input$gamePitcherName)
450
+ if (name != "" && !(name %in% values$gamePitchers)) {
451
+ values$gamePitchers <- c(values$gamePitchers, name)
452
+ updateSelectInput(session, "currentGamePitcher", choices = values$gamePitchers, selected = name)
453
+ updateTextInput(session, "gamePitcherName", value = "")
454
+ showNotification(paste("Added", name, "to game"), type = "message")
455
+ }
456
+ })
457
+
458
+ output$gameStats <- renderText({
459
+ req(input$currentGamePitcher)
460
+ data <- values$gameData %>% filter(Pitcher == input$currentGamePitcher)
461
+ if (nrow(data) == 0) return("No pitches yet")
462
+
463
+ total <- nrow(data)
464
+
465
+ fb_data <- data %>% filter(PitchCall %in% c("Fastball", "Sinker", "Cutter"))
466
+ fb_total <- nrow(fb_data)
467
+
468
+ os_data <- data %>% filter(PitchCall %in% c("Curveball", "Slider", "Changeup"))
469
+ os_total <- nrow(os_data)
470
+
471
+ paste0(
472
+ "Total Pitches: ", total, "\n",
473
+ "==================\n",
474
+ "Fastballs: ", fb_total, "\n",
475
+ "Offspeed: ", os_total
476
+ )
477
+ })
478
+
479
+ observeEvent(input$game_pitch_fb, { values$gamePitchType <- "Fastball" })
480
+ observeEvent(input$game_pitch_cb, { values$gamePitchType <- "Curveball" })
481
+ observeEvent(input$game_pitch_ch, { values$gamePitchType <- "Changeup" })
482
+ observeEvent(input$game_pitch_sl, { values$gamePitchType <- "Slider" })
483
+ observeEvent(input$game_pitch_ct, { values$gamePitchType <- "Cutter" })
484
+ observeEvent(input$game_pitch_sn, { values$gamePitchType <- "Sinker" })
485
+
486
+ observeEvent(input$game_int_up, { values$gameIntendedVert <- "Up" })
487
+ observeEvent(input$game_int_down, { values$gameIntendedVert <- "Down" })
488
+
489
+ observeEvent(input$game_int_away, { values$gameIntendedHorz <- "Away" })
490
+ observeEvent(input$game_int_middle, { values$gameIntendedHorz <- "Middle" })
491
+ observeEvent(input$game_int_in, { values$gameIntendedHorz <- "In" })
492
+
493
+ observeEvent(input$recordGamePitch, {
494
+ req(input$currentGamePitcher, values$gamePitchType)
495
+
496
+ intended_loc <- paste(c(values$gameIntendedVert, values$gameIntendedHorz), collapse = " + ")
497
+ if (intended_loc == "") intended_loc <- NA
498
+
499
+ new_pitch <- data.frame(
500
+ Pitcher = input$currentGamePitcher,
501
+ PitchCall = values$gamePitchType,
502
+ BatterSide = input$gameBatterSide,
503
+ IntendedLocation = intended_loc,
504
+ Timestamp = Sys.time(),
505
+ stringsAsFactors = FALSE
506
+ )
507
+
508
+ values$gameData <- rbind(values$gameData, new_pitch)
509
+
510
+ values$gamePitchType <- NULL
511
+ values$gameIntendedVert <- NULL
512
+ values$gameIntendedHorz <- NULL
513
+
514
+ showNotification("Pitch recorded!", type = "message", duration = 1)
515
+ })
516
+
517
+ observeEvent(input$clearGamePitch, {
518
+ values$gamePitchType <- NULL
519
+ values$gameIntendedVert <- NULL
520
+ values$gameIntendedHorz <- NULL
521
+ })
522
+
523
+ output$gamePitches <- DT::renderDataTable({
524
+ if (nrow(values$gameData) == 0) {
525
+ return(data.frame(Message = "No pitches recorded"))
526
+ }
527
+
528
+ recent <- tail(values$gameData, 50) %>%
529
+ arrange(desc(Timestamp)) %>%
530
+ select(Pitcher, PitchCall, BatterSide, IntendedLocation)
531
+
532
+ DT::datatable(recent, options = list(pageLength = 50, dom = 't'), rownames = FALSE)
533
+ })
534
+
535
+ # LEADERBOARD SECTION
536
  observeEvent(input$uploadCSV, {
537
  req(input$uploadCSV)
538
 
 
556
  showNotification("Cleared uploaded data", type = "warning")
557
  })
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  generate_leaderboard <- function() {
560
  all_data <- bind_rows(values$pitchData, values$uploadedData)
561
 
 
575
  OS_Total = sum(PitchType %in% c("Curveball", "Slider", "Changeup")),
576
  OS_Strikes = sum(Result == "Strike" & PitchType %in% c("Curveball", "Slider", "Changeup")),
577
  OS_StrikeRate = if_else(OS_Total > 0, round(OS_Strikes / OS_Total * 100, 1), NA_real_),
 
 
 
 
 
578
  .groups = 'drop'
579
  ) %>%
580
  filter(Total >= input$minPitches)
 
582
  return(leaderboard)
583
  }
584
 
 
585
  prepare_leaderboard_display <- function(leaderboard) {
586
  if (is.null(leaderboard)) return(NULL)
587
 
 
588
  leaderboard_display <- leaderboard
589
  if ("Pitcher" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(PLAYER = Pitcher)
590
  if ("FB_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`FB STRIKE%` = FB_StrikeRate)
591
  if ("OS_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OS STRIKE%` = OS_StrikeRate)
592
  if ("StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OVR STRIKE%` = StrikeRate)
 
 
 
 
 
593
  if ("Total" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`TOTAL PITCHES` = Total)
594
 
 
595
  if (!is.null(input$leaderboardCols) && length(input$leaderboardCols) > 0) {
596
  cols_to_keep <- input$leaderboardCols
597
 
 
600
  "Total" = "TOTAL PITCHES",
601
  "StrikeRate" = "OVR STRIKE%",
602
  "FB_StrikeRate" = "FB STRIKE%",
603
+ "OS_StrikeRate" = "OS STRIKE%"
 
 
 
 
 
604
  )
605
 
606
  display_cols <- col_mapping[cols_to_keep]
 
647
  border = '2px solid black'
648
  )
649
 
 
650
  pct_cols <- intersect(
651
+ c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%"),
652
  colnames(leaderboard_display)
653
  )
654
 
 
669
  dt
670
  })
671
 
 
672
  output$downloadLeaderboardCSV <- downloadHandler(
673
  filename = function() {
674
  paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".csv")
 
684
  }
685
  )
686
 
 
687
  output$downloadLeaderboardPNG <- downloadHandler(
688
+ filename = function() {
689
+ paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".png")
690
  },
691
  content = function(file) {
692
  leaderboard <- generate_leaderboard()
 
696
  return()
697
  }
698
 
 
699
  leaderboard_display <- prepare_leaderboard_display(leaderboard)
700
 
701
  if (is.null(leaderboard_display)) {
 
703
  return()
704
  }
705
 
 
706
  dt <- DT::datatable(
707
  leaderboard_display,
708
  options = list(
 
729
  border = '2px solid black'
730
  )
731
 
 
732
  pct_cols <- intersect(
733
+ c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%"),
734
  colnames(leaderboard_display)
735
  )
736
 
 
748
  )
749
  }
750
 
 
751
  temp_html <- tempfile(fileext = ".html")
752
  htmlwidgets::saveWidget(dt, temp_html, selfcontained = TRUE)
753
 
 
754
  tryCatch({
755
  webshot2::webshot(temp_html, file = file, vwidth = 1400, vheight = 1000, delay = 0.5)
756
  showNotification("Leaderboard PNG downloaded successfully!", type = "message")
 
758
  showNotification(paste("Error creating PNG:", e$message), type = "error")
759
  })
760
 
 
761
  unlink(temp_html)
762
  }
763
  )
764
 
765
+ # EXPORT HANDLERS
766
  output$downloadData <- downloadHandler(
767
  filename = function() {
768
  base_name <- if (!is.null(input$exportFilename) && input$exportFilename != "") {
 
777
  write_csv(values$pitchData, file)
778
  }
779
  )
780
+
781
+ output$downloadGameData <- downloadHandler(
782
+ filename = function() {
783
+ base_name <- if (!is.null(input$exportGameFilename) && input$exportGameFilename != "") {
784
+ gsub(" ", "_", trimws(input$exportGameFilename))
785
+ } else {
786
+ "Game_Data"
787
+ }
788
+ date_str <- format(Sys.Date(), "%m%d")
789
+ paste0(base_name, "_", date_str, ".csv")
790
+ },
791
+ content = function(file) {
792
+ write_csv(values$gameData, file)
793
+ }
794
+ )
795
  }
796
 
797
  shinyApp(ui = ui, server = server)