igroffman commited on
Commit
fae7cad
Β·
1 Parent(s): fac5de6

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +409 -5
app.R CHANGED
@@ -789,6 +789,30 @@ app_ui <- fluidPage(
789
  padding: 20px;
790
  margin-bottom: 15px;
791
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  "))
793
  ),
794
 
@@ -957,7 +981,7 @@ app_ui <- fluidPage(
957
  )
958
  ),
959
 
960
- # ── NEW: Rule-Based Pitch Retagging Panel ──
961
  hr(),
962
  fluidRow(
963
  column(12,
@@ -1043,6 +1067,70 @@ app_ui <- fluidPage(
1043
  )
1044
  ),
1045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1046
  # Download Tab
1047
  tabPanel(
1048
  "Download",
@@ -1168,9 +1256,21 @@ server <- function(input, output, session) {
1168
  merge_result <- reactiveVal(NULL)
1169
  scraped_data <- reactiveVal(NULL)
1170
  scrape_polling <- reactiveVal(FALSE)
1171
-
1172
  scrape_status_msg <- reactiveVal("Ready.")
1173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1174
  # Handle column selection buttons
1175
  observeEvent(input$select_all_cols, {
1176
  updateCheckboxGroupInput(session, "columns_to_remove",
@@ -1210,9 +1310,28 @@ server <- function(input, output, session) {
1210
  processed_data(processed_df)
1211
  plot_data(processed_df)
1212
 
 
 
 
1213
  return(processed_df)
1214
  }
1215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1216
  # Re-process data when date format changes
1217
  observeEvent(input$date_format, {
1218
  req(input$file)
@@ -1528,6 +1647,13 @@ server <- function(input, output, session) {
1528
  "\u25CB Bat tracking: Not uploaded"
1529
  }
1530
 
 
 
 
 
 
 
 
1531
  summary_text <- paste(
1532
  paste0("\u2713 ", format_label, " file processed successfully!"),
1533
  paste("\u2713 Original columns:", ncol(original_df)),
@@ -1535,6 +1661,7 @@ server <- function(input, output, session) {
1535
  paste("\u2713 Rows processed:", nrow(df)),
1536
  removed_cols_text,
1537
  bat_tracking_text,
 
1538
  "\u2713 Duplicates removed",
1539
  paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
1540
  sep = "\n"
@@ -2107,6 +2234,272 @@ server <- function(input, output, session) {
2107
  # End Rule-Based Retagging
2108
  # ══════════════════════════════════════════════════════════════
2109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2110
  # Click info output
2111
  output$click_info <- renderText({
2112
  if (!is.null(selected_pitch())) {
@@ -2124,6 +2517,7 @@ server <- function(input, output, session) {
2124
  req(processed_data())
2125
  df <- processed_data()
2126
  result <- merge_result()
 
2127
 
2128
  bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
2129
  paste("Bat tracking data:", result$matched, "pitches with swing metrics")
@@ -2131,6 +2525,13 @@ server <- function(input, output, session) {
2131
  "Bat tracking data: None"
2132
  }
2133
 
 
 
 
 
 
 
 
2134
  summary_text <- paste(
2135
  paste("Total rows:", nrow(df)),
2136
  paste("Total columns:", ncol(df)),
@@ -2153,6 +2554,7 @@ server <- function(input, output, session) {
2153
  "TaggedPitchType column not available"
2154
  }),
2155
  bat_tracking_summary,
 
2156
  paste("Source format:", toupper(uploaded_file_type())),
2157
  paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
2158
  sep = "\n"
@@ -2161,7 +2563,7 @@ server <- function(input, output, session) {
2161
  return(summary_text)
2162
  })
2163
 
2164
- # Download handler: CSV or Parquet with custom filename
2165
  output$downloadData <- downloadHandler(
2166
  filename = function() {
2167
  base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename)
@@ -2171,10 +2573,12 @@ server <- function(input, output, session) {
2171
  paste0(base_name, ".", ext)
2172
  },
2173
  content = function(file) {
 
 
2174
  if (input$download_format == "parquet") {
2175
- arrow::write_parquet(processed_data(), file)
2176
  } else {
2177
- write.csv(processed_data(), file, row.names = FALSE)
2178
  }
2179
  }
2180
  )
 
789
  padding: 20px;
790
  margin-bottom: 15px;
791
  }
792
+
793
+ /* Catcher notes styling */
794
+ .catcher-notes-input-box {
795
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
796
+ border: 2px solid darkcyan;
797
+ border-radius: 15px;
798
+ padding: 20px;
799
+ }
800
+
801
+ .catcher-note-entry {
802
+ background: #fff;
803
+ border: 1px solid rgba(0,139,139,.15);
804
+ border-radius: 10px;
805
+ padding: 12px 16px;
806
+ margin-bottom: 8px;
807
+ display: flex;
808
+ justify-content: space-between;
809
+ align-items: center;
810
+ }
811
+
812
+ .catcher-note-entry:hover {
813
+ border-color: darkcyan;
814
+ box-shadow: 0 2px 8px rgba(0,139,139,.12);
815
+ }
816
  "))
817
  ),
818
 
 
981
  )
982
  ),
983
 
984
+ # ── Rule-Based Pitch Retagging Panel ──
985
  hr(),
986
  fluidRow(
987
  column(12,
 
1067
  )
1068
  ),
1069
 
1070
+ # ══════════════════════════════════════════════════════════════
1071
+ # Catcher Notes Tab
1072
+ # ══════════════════════════════════════════════════════════════
1073
+ tabPanel(
1074
+ "Catcher Notes",
1075
+ fluidRow(
1076
+ column(5,
1077
+ div(class = "catcher-notes-input-box",
1078
+ h3("Add Catcher Note", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
1079
+ p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
1080
+ "Log catcher events (throws, wild pitches, passed balls). ",
1081
+ "Each note is matched to the pitch row by Catcher, Batter, Inning, and Count, ",
1082
+ "then written into a CatcherNotes column on download."),
1083
+
1084
+ fluidRow(
1085
+ column(6, selectInput("cn_catcher", "Catcher:", choices = NULL)),
1086
+ column(6, selectInput("cn_batter", "Batter:", choices = NULL))
1087
+ ),
1088
+
1089
+ fluidRow(
1090
+ column(4, numericInput("cn_inning", "Inning:", value = 1, min = 1, max = 20, step = 1)),
1091
+ column(4, numericInput("cn_balls", "Balls:", value = 0, min = 0, max = 3, step = 1)),
1092
+ column(4, numericInput("cn_strikes", "Strikes:", value = 0, min = 0, max = 2, step = 1))
1093
+ ),
1094
+
1095
+ selectInput("cn_result", "Result:",
1096
+ choices = c("2B Out", "2B Safe", "3B Out", "3B Safe",
1097
+ "Wild Pitch", "Passed Ball",
1098
+ "Pickoff Attempt", "Pickoff Out",
1099
+ "Blocked Ball", "Other"),
1100
+ selected = "2B Out"),
1101
+
1102
+ conditionalPanel(
1103
+ condition = "input.cn_result == 'Other'",
1104
+ textInput("cn_custom_result", "Custom Note:", placeholder = "Describe the event...")
1105
+ ),
1106
+
1107
+ br(),
1108
+ actionButton("cn_add_btn", "Add Note", class = "btn-success",
1109
+ style = "width: 100%; font-weight: bold; font-size: 15px;"),
1110
+ br(), br(),
1111
+ uiOutput("cn_match_feedback")
1112
+ )
1113
+ ),
1114
+
1115
+ column(7,
1116
+ h3("Logged Catcher Notes"),
1117
+ p(style = "color: #666; font-size: 13px;",
1118
+ "These notes will be merged into the CatcherNotes column when you download the data."),
1119
+ DT::dataTableOutput("cn_notes_table"),
1120
+ br(),
1121
+ fluidRow(
1122
+ column(6,
1123
+ actionButton("cn_clear_all_btn", "Clear All Notes", class = "btn-danger",
1124
+ style = "width: 100%;")
1125
+ ),
1126
+ column(6,
1127
+ verbatimTextOutput("cn_summary")
1128
+ )
1129
+ )
1130
+ )
1131
+ )
1132
+ ),
1133
+
1134
  # Download Tab
1135
  tabPanel(
1136
  "Download",
 
1256
  merge_result <- reactiveVal(NULL)
1257
  scraped_data <- reactiveVal(NULL)
1258
  scrape_polling <- reactiveVal(FALSE)
 
1259
  scrape_status_msg <- reactiveVal("Ready.")
1260
 
1261
+ # Catcher Notes: stored as a list of data frames, each row is one note
1262
+ catcher_notes_list <- reactiveVal(data.frame(
1263
+ NoteID = integer(0),
1264
+ Catcher = character(0),
1265
+ Batter = character(0),
1266
+ Inning = integer(0),
1267
+ Balls = integer(0),
1268
+ Strikes = integer(0),
1269
+ Result = character(0),
1270
+ MatchedRow = integer(0),
1271
+ stringsAsFactors = FALSE
1272
+ ))
1273
+
1274
  # Handle column selection buttons
1275
  observeEvent(input$select_all_cols, {
1276
  updateCheckboxGroupInput(session, "columns_to_remove",
 
1310
  processed_data(processed_df)
1311
  plot_data(processed_df)
1312
 
1313
+ # Update catcher notes dropdowns
1314
+ update_catcher_note_choices(processed_df)
1315
+
1316
  return(processed_df)
1317
  }
1318
 
1319
+ # Helper to populate Catcher Notes dropdowns from current data
1320
+ update_catcher_note_choices <- function(df) {
1321
+ if (!is.null(df)) {
1322
+ if ("Catcher" %in% names(df)) {
1323
+ catchers <- sort(unique(df$Catcher[!is.na(df$Catcher) & df$Catcher != ""]))
1324
+ updateSelectInput(session, "cn_catcher", choices = catchers,
1325
+ selected = if (length(catchers) > 0) catchers[1] else NULL)
1326
+ }
1327
+ if ("Batter" %in% names(df)) {
1328
+ batters <- sort(unique(df$Batter[!is.na(df$Batter) & df$Batter != ""]))
1329
+ updateSelectInput(session, "cn_batter", choices = batters,
1330
+ selected = if (length(batters) > 0) batters[1] else NULL)
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
  # Re-process data when date format changes
1336
  observeEvent(input$date_format, {
1337
  req(input$file)
 
1647
  "\u25CB Bat tracking: Not uploaded"
1648
  }
1649
 
1650
+ notes <- catcher_notes_list()
1651
+ notes_text <- if (nrow(notes) > 0) {
1652
+ paste("\u2713 Catcher notes:", nrow(notes), "logged")
1653
+ } else {
1654
+ "\u25CB Catcher notes: None"
1655
+ }
1656
+
1657
  summary_text <- paste(
1658
  paste0("\u2713 ", format_label, " file processed successfully!"),
1659
  paste("\u2713 Original columns:", ncol(original_df)),
 
1661
  paste("\u2713 Rows processed:", nrow(df)),
1662
  removed_cols_text,
1663
  bat_tracking_text,
1664
+ notes_text,
1665
  "\u2713 Duplicates removed",
1666
  paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
1667
  sep = "\n"
 
2234
  # End Rule-Based Retagging
2235
  # ══════════════════════════════════════════════════════════════
2236
 
2237
+ # ══════════════════════════════════════════════════════════════
2238
+ # Catcher Notes β€” Server Logic
2239
+ # ══════════════════════════════════════════════════════════════
2240
+
2241
+ # Add a catcher note
2242
+ observeEvent(input$cn_add_btn, {
2243
+ req(processed_data(), input$cn_catcher, input$cn_batter)
2244
+
2245
+ df <- processed_data()
2246
+
2247
+ # Determine the result text
2248
+ result_text <- input$cn_result
2249
+ if (result_text == "Other" && !is.null(input$cn_custom_result) && nzchar(trimws(input$cn_custom_result))) {
2250
+ result_text <- trimws(input$cn_custom_result)
2251
+ }
2252
+
2253
+ # Find the matching row(s): Catcher + Batter + Inning + Balls + Strikes
2254
+ # We match the LAST pitch in that count for that matchup in that inning
2255
+ # (the event most likely happened on the final pitch of that count)
2256
+ has_catcher <- "Catcher" %in% names(df)
2257
+ has_batter <- "Batter" %in% names(df)
2258
+ has_inning <- "Inning" %in% names(df)
2259
+ has_balls <- "Balls" %in% names(df)
2260
+ has_strikes <- "Strikes" %in% names(df)
2261
+
2262
+ candidates <- df
2263
+ if (has_catcher) candidates <- candidates %>% filter(Catcher == input$cn_catcher)
2264
+ if (has_batter) candidates <- candidates %>% filter(Batter == input$cn_batter)
2265
+ if (has_inning) candidates <- candidates %>% filter(Inning == input$cn_inning)
2266
+ if (has_balls) candidates <- candidates %>% filter(Balls == input$cn_balls)
2267
+ if (has_strikes) candidates <- candidates %>% filter(Strikes == input$cn_strikes)
2268
+
2269
+ # Get the row index in the full dataframe for the last matching pitch
2270
+ if (nrow(candidates) > 0) {
2271
+ # Find which rows in the full df match
2272
+ match_idx <- which(
2273
+ (if (has_catcher) df$Catcher == input$cn_catcher else TRUE) &
2274
+ (if (has_batter) df$Batter == input$cn_batter else TRUE) &
2275
+ (if (has_inning) df$Inning == input$cn_inning else TRUE) &
2276
+ (if (has_balls) df$Balls == input$cn_balls else TRUE) &
2277
+ (if (has_strikes) df$Strikes == input$cn_strikes else TRUE)
2278
+ )
2279
+ matched_row <- max(match_idx) # last pitch at that count
2280
+ } else {
2281
+ matched_row <- NA_integer_
2282
+ }
2283
+
2284
+ # Build the new note
2285
+ notes <- catcher_notes_list()
2286
+ new_id <- if (nrow(notes) == 0) 1L else max(notes$NoteID) + 1L
2287
+
2288
+ new_note <- data.frame(
2289
+ NoteID = new_id,
2290
+ Catcher = input$cn_catcher,
2291
+ Batter = input$cn_batter,
2292
+ Inning = as.integer(input$cn_inning),
2293
+ Balls = as.integer(input$cn_balls),
2294
+ Strikes = as.integer(input$cn_strikes),
2295
+ Result = result_text,
2296
+ MatchedRow = matched_row,
2297
+ stringsAsFactors = FALSE
2298
+ )
2299
+
2300
+ catcher_notes_list(bind_rows(notes, new_note))
2301
+
2302
+ # Show match feedback
2303
+ output$cn_match_feedback <- renderUI({
2304
+ if (!is.na(matched_row)) {
2305
+ pitch_info <- df[matched_row, ]
2306
+ detail_parts <- c()
2307
+ if ("PitchNo" %in% names(pitch_info) && !is.na(pitch_info$PitchNo))
2308
+ detail_parts <- c(detail_parts, paste("Pitch #", pitch_info$PitchNo))
2309
+ if ("PitchCall" %in% names(pitch_info) && !is.na(pitch_info$PitchCall))
2310
+ detail_parts <- c(detail_parts, pitch_info$PitchCall)
2311
+ if ("Pitcher" %in% names(pitch_info) && !is.na(pitch_info$Pitcher))
2312
+ detail_parts <- c(detail_parts, paste("vs", pitch_info$Pitcher))
2313
+
2314
+ div(class = "merge-status-box merge-success",
2315
+ style = "margin-top: 10px;",
2316
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
2317
+ paste0("\u2713 Matched to row ", matched_row)),
2318
+ if (length(detail_parts) > 0)
2319
+ p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;",
2320
+ paste(detail_parts, collapse = " | "))
2321
+ )
2322
+ } else {
2323
+ div(class = "merge-status-box merge-warning",
2324
+ style = "margin-top: 10px;",
2325
+ p(style = "margin: 0; font-weight: 600; color: #856404;",
2326
+ paste0("\u26A0 No matching pitch found for ", input$cn_catcher,
2327
+ " / ", input$cn_batter, " / Inn ", input$cn_inning,
2328
+ " / ", input$cn_balls, "-", input$cn_strikes)),
2329
+ p(style = "margin: 4px 0 0 0; color: #856404; font-size: 13px;",
2330
+ "Note saved anyway \u2014 it will appear in CatcherNotes column as unmatched.")
2331
+ )
2332
+ }
2333
+ })
2334
+
2335
+ showNotification(
2336
+ paste0("Added: ", result_text, " (", input$cn_catcher, " / ", input$cn_batter,
2337
+ " / Inn ", input$cn_inning, " / ", input$cn_balls, "-", input$cn_strikes, ")"),
2338
+ type = "message", duration = 3
2339
+ )
2340
+ })
2341
+
2342
+ # Render the notes table
2343
+ output$cn_notes_table <- DT::renderDataTable({
2344
+ notes <- catcher_notes_list()
2345
+ if (nrow(notes) == 0) return(NULL)
2346
+
2347
+ display_notes <- notes %>%
2348
+ mutate(
2349
+ Count = paste0(Balls, "-", Strikes),
2350
+ Match = ifelse(is.na(MatchedRow), "\u2717 No match", paste0("\u2713 Row ", MatchedRow))
2351
+ ) %>%
2352
+ select(NoteID, Catcher, Batter, Inning, Count, Result, Match)
2353
+
2354
+ DT::datatable(
2355
+ display_notes,
2356
+ options = list(
2357
+ scrollX = TRUE, pageLength = 15, dom = "tip",
2358
+ columnDefs = list(list(className = "dt-center", targets = "_all"))
2359
+ ),
2360
+ rownames = FALSE,
2361
+ selection = "single",
2362
+ callback = DT::JS("
2363
+ table.on('click', 'tr', function() {
2364
+ var data = table.row(this).data();
2365
+ if (data) {
2366
+ Shiny.setInputValue('cn_delete_row', data[0], {priority: 'event'});
2367
+ }
2368
+ });
2369
+ ")
2370
+ ) %>%
2371
+ DT::formatStyle("Match",
2372
+ color = DT::styleEqual(c("\u2717 No match"), c("#dc3545")),
2373
+ fontWeight = "bold"
2374
+ )
2375
+ })
2376
+
2377
+ # Delete a single note by clicking its row
2378
+ observeEvent(input$cn_delete_row, {
2379
+ notes <- catcher_notes_list()
2380
+ note_id <- as.integer(input$cn_delete_row)
2381
+
2382
+ if (note_id %in% notes$NoteID) {
2383
+ showModal(modalDialog(
2384
+ title = "Delete Catcher Note?",
2385
+ p(paste("Remove note #", note_id, "?")),
2386
+ footer = tagList(
2387
+ actionButton("cn_confirm_delete", "Delete", class = "btn-danger"),
2388
+ modalButton("Cancel")
2389
+ ),
2390
+ size = "s", easyClose = TRUE
2391
+ ))
2392
+ }
2393
+ })
2394
+
2395
+ observeEvent(input$cn_confirm_delete, {
2396
+ notes <- catcher_notes_list()
2397
+ note_id <- as.integer(input$cn_delete_row)
2398
+ catcher_notes_list(notes %>% filter(NoteID != note_id))
2399
+ removeModal()
2400
+ showNotification(paste("Deleted note #", note_id), type = "message", duration = 2)
2401
+ })
2402
+
2403
+ # Clear all notes
2404
+ observeEvent(input$cn_clear_all_btn, {
2405
+ showModal(modalDialog(
2406
+ title = "Clear All Catcher Notes?",
2407
+ p("This will remove all logged catcher notes. This cannot be undone."),
2408
+ footer = tagList(
2409
+ actionButton("cn_confirm_clear_all", "Clear All", class = "btn-danger"),
2410
+ modalButton("Cancel")
2411
+ ),
2412
+ size = "s", easyClose = TRUE
2413
+ ))
2414
+ })
2415
+
2416
+ observeEvent(input$cn_confirm_clear_all, {
2417
+ catcher_notes_list(data.frame(
2418
+ NoteID = integer(0), Catcher = character(0), Batter = character(0),
2419
+ Inning = integer(0), Balls = integer(0), Strikes = integer(0),
2420
+ Result = character(0), MatchedRow = integer(0), stringsAsFactors = FALSE
2421
+ ))
2422
+ removeModal()
2423
+ output$cn_match_feedback <- renderUI({ NULL })
2424
+ showNotification("All catcher notes cleared.", type = "message", duration = 2)
2425
+ })
2426
+
2427
+ # Catcher notes summary
2428
+ output$cn_summary <- renderText({
2429
+ notes <- catcher_notes_list()
2430
+ if (nrow(notes) == 0) return("No notes logged yet.")
2431
+
2432
+ n_matched <- sum(!is.na(notes$MatchedRow))
2433
+ n_unmatched <- sum(is.na(notes$MatchedRow))
2434
+ result_counts <- table(notes$Result)
2435
+ result_str <- paste(names(result_counts), "(", result_counts, ")", collapse = ", ")
2436
+
2437
+ paste(
2438
+ paste("Total notes:", nrow(notes)),
2439
+ paste("Matched:", n_matched, "| Unmatched:", n_unmatched),
2440
+ paste("Results:", result_str),
2441
+ sep = "\n"
2442
+ )
2443
+ })
2444
+
2445
+ # ══════════════════════════════════════════════════════════════
2446
+ # Helper: merge catcher notes into data for download
2447
+ # ══════════════════════════════════════════════════════════════
2448
+
2449
+ build_download_data <- function() {
2450
+ df <- processed_data()
2451
+ if (is.null(df)) return(NULL)
2452
+
2453
+ notes <- catcher_notes_list()
2454
+
2455
+ if (nrow(notes) == 0) return(df)
2456
+
2457
+ # Initialize CatcherNotes column if not present
2458
+ if (!"CatcherNotes" %in% names(df)) {
2459
+ df$CatcherNotes <- NA_character_
2460
+ }
2461
+
2462
+ # For each note, write the result into the matched row
2463
+ # If multiple notes match the same row, concatenate with " | "
2464
+ for (i in seq_len(nrow(notes))) {
2465
+ row_idx <- notes$MatchedRow[i]
2466
+ result <- notes$Result[i]
2467
+
2468
+ if (!is.na(row_idx) && row_idx >= 1 && row_idx <= nrow(df)) {
2469
+ existing <- df$CatcherNotes[row_idx]
2470
+ if (is.na(existing) || existing == "") {
2471
+ df$CatcherNotes[row_idx] <- result
2472
+ } else {
2473
+ df$CatcherNotes[row_idx] <- paste(existing, result, sep = " | ")
2474
+ }
2475
+ }
2476
+ }
2477
+
2478
+ # Also add unmatched notes as a summary attribute (append to last row as a note)
2479
+ unmatched <- notes %>% filter(is.na(MatchedRow))
2480
+ if (nrow(unmatched) > 0) {
2481
+ unmatched_texts <- paste0(
2482
+ unmatched$Result, " (", unmatched$Catcher, "/", unmatched$Batter,
2483
+ " Inn", unmatched$Inning, " ", unmatched$Balls, "-", unmatched$Strikes, ")"
2484
+ )
2485
+ # Put unmatched notes in the last row's CatcherNotes with a prefix
2486
+ last_row <- nrow(df)
2487
+ existing <- df$CatcherNotes[last_row]
2488
+ unmatched_str <- paste0("[UNMATCHED] ", paste(unmatched_texts, collapse = "; "))
2489
+ if (is.na(existing) || existing == "") {
2490
+ df$CatcherNotes[last_row] <- unmatched_str
2491
+ } else {
2492
+ df$CatcherNotes[last_row] <- paste(existing, unmatched_str, sep = " | ")
2493
+ }
2494
+ }
2495
+
2496
+ return(df)
2497
+ }
2498
+
2499
+ # ══════════════════════════════════════════════════════════════
2500
+ # End Catcher Notes
2501
+ # ══════════════════════════════════════════════════════════════
2502
+
2503
  # Click info output
2504
  output$click_info <- renderText({
2505
  if (!is.null(selected_pitch())) {
 
2517
  req(processed_data())
2518
  df <- processed_data()
2519
  result <- merge_result()
2520
+ notes <- catcher_notes_list()
2521
 
2522
  bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
2523
  paste("Bat tracking data:", result$matched, "pitches with swing metrics")
 
2525
  "Bat tracking data: None"
2526
  }
2527
 
2528
+ notes_summary <- if (nrow(notes) > 0) {
2529
+ n_matched <- sum(!is.na(notes$MatchedRow))
2530
+ paste0("Catcher notes: ", nrow(notes), " total (", n_matched, " matched to rows)")
2531
+ } else {
2532
+ "Catcher notes: None"
2533
+ }
2534
+
2535
  summary_text <- paste(
2536
  paste("Total rows:", nrow(df)),
2537
  paste("Total columns:", ncol(df)),
 
2554
  "TaggedPitchType column not available"
2555
  }),
2556
  bat_tracking_summary,
2557
+ notes_summary,
2558
  paste("Source format:", toupper(uploaded_file_type())),
2559
  paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
2560
  sep = "\n"
 
2563
  return(summary_text)
2564
  })
2565
 
2566
+ # Download handler: CSV or Parquet with custom filename β€” NOW includes catcher notes
2567
  output$downloadData <- downloadHandler(
2568
  filename = function() {
2569
  base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename)
 
2573
  paste0(base_name, ".", ext)
2574
  },
2575
  content = function(file) {
2576
+ download_df <- build_download_data()
2577
+
2578
  if (input$download_format == "parquet") {
2579
+ arrow::write_parquet(download_df, file)
2580
  } else {
2581
+ write.csv(download_df, file, row.names = FALSE)
2582
  }
2583
  }
2584
  )