TealPages / app.R
igroffman's picture
Update app.R
f3a2453 verified
library(shiny)
library(DT)
library(dplyr)
library(readxl)
library(scales)
library(readr)
library(tidyverse)
library(bslib)
library(htmltools)
library(shinyWidgets)
library(ggplot2)
library(shinyjs)
library(jsonlite)
library(purrr)
library(httr)
library(tibble)
# ═══════════════════════════════════════════════════════════════════════════════
# ESPN SCOREBOARD API HELPERS
# ═══════════════════════════════════════════════════════════════════════════════
`%||%` <- function(a, b) if (!is.null(a) && length(a) > 0) a else b
normalize_team_name <- function(value) {
text <- trimws(as.character(value %||% ""))
if (!nzchar(text)) return("")
text <- tolower(text)
text <- gsub("&", " and ", text, fixed = TRUE)
text <- gsub("[^a-z0-9]+", " ", text)
text <- gsub("\\s+", " ", text)
trimws(text)
}
extract_competitor_rank <- function(competitor) {
candidates <- list(
competitor$curatedRank$current, competitor$curatedRank$currentRank,
competitor$curatedRank$rank, competitor$curatedRank, competitor$rank,
competitor$team$curatedRank$current, competitor$team$curatedRank$currentRank,
competitor$team$curatedRank$rank, competitor$team$rank
)
for (candidate in candidates) {
if (is.null(candidate) || length(candidate) == 0) next
value <- suppressWarnings(as.integer(candidate[[1]]))
if (!is.na(value) && value > 0) return(value)
}
NA_integer_
}
extract_bool <- function(value) {
if (is.null(value) || length(value) == 0) return(NA)
first <- value[[1]]
bool <- suppressWarnings(as.logical(first))
if (!is.na(bool)) return(bool)
text <- tolower(as.character(first))
if (text %in% c("true", "t", "1", "yes", "y")) return(TRUE)
if (text %in% c("false", "f", "0", "no", "n")) return(FALSE)
NA
}
extract_logo_url <- function(competitor) {
team_id <- as.character(competitor$team$id %||% NA_character_)
team_logo_by_id <- if (!is.na(team_id) && nzchar(team_id)) {
sprintf("https://a.espncdn.com/i/teamlogos/ncaa/500/%s.png", team_id)
} else { NA_character_ }
first_logo <- NULL
if (is.list(competitor$team$logos) && length(competitor$team$logos) > 0)
first_logo <- competitor$team$logos[[1]]$href %||% competitor$team$logos[[1]]$url
alt_logo <- NULL
if (is.list(competitor$logos) && length(competitor$logos) > 0)
alt_logo <- competitor$logos[[1]]$href %||% competitor$logos[[1]]$url
candidates <- list(first_logo, competitor$team$logo, alt_logo, competitor$logo, team_logo_by_id)
for (candidate in candidates) {
if (is.null(candidate) || length(candidate) == 0) next
value <- trimws(as.character(candidate[[1]]))
if (!is.na(value) && nzchar(value)) return(value)
}
NA_character_
}
extract_game_location <- function(competition, event) {
venue <- competition$venue %||% event$venue %||% list()
venue_name <- trimws(as.character(venue$fullName %||% venue$name %||% venue$shortName %||% NA_character_))
city <- trimws(as.character(venue$address$city %||% NA_character_))
state <- trimws(as.character(venue$address$state %||% venue$address$stateAbbrev %||% NA_character_))
city_state_parts <- c(city, state)
city_state_parts <- city_state_parts[!is.na(city_state_parts) & nzchar(city_state_parts)]
city_state <- if (length(city_state_parts) > 0) paste(city_state_parts, collapse = ", ") else NA_character_
if (!is.na(venue_name) && nzchar(venue_name) && !is.na(city_state) && nzchar(city_state)) return(paste(venue_name, city_state, sep = ", "))
if (!is.na(venue_name) && nzchar(venue_name)) return(venue_name)
if (!is.na(city_state) && nzchar(city_state)) return(city_state)
NA_character_
}
extract_broadcast <- function(competition) {
geo <- competition$geoBroadcasts %||% list()
if (length(geo) > 0) {
networks <- unique(vapply(geo, function(g) {
as.character(g$media$shortName %||% g$media$name %||% NA_character_)
}, character(1)))
networks <- networks[!is.na(networks) & nzchar(networks)]
if (length(networks) > 0) return(paste(networks, collapse = " / "))
}
bc <- competition$broadcasts %||% list()
if (length(bc) > 0) {
networks <- unique(unlist(lapply(bc, function(b) as.character(unlist(b$names %||% list())))))
networks <- networks[!is.na(networks) & nzchar(networks)]
if (length(networks) > 0) return(paste(networks, collapse = " / "))
}
NA_character_
}
extract_game_start_time <- function(status_short_detail, status_detail, competition, event) {
time_pattern <- "([0-9]{1,2}:[0-9]{2}\\s*[APap][Mm](?:\\s*[A-Za-z]{1,4})?)"
candidates <- c(as.character(status_short_detail %||% ""), as.character(status_detail %||% ""))
for (candidate in candidates) {
text <- trimws(candidate)
if (is.na(text) || !nzchar(text)) next
m <- regexpr(time_pattern, text, perl = TRUE)
if (m[1] != -1) { out <- regmatches(text, m)[1]; out <- toupper(gsub("\\s+", " ", trimws(out))); return(out) }
}
start_raw <- as.character(competition$date %||% event$date %||% NA_character_)
if (!is.na(start_raw) && nzchar(start_raw)) {
parsed <- suppressWarnings(as.POSIXct(start_raw, tz = "America/New_York"))
if (!is.na(parsed)) { out <- format(parsed, "%I:%M %p"); out <- sub("^0", "", out); return(paste(out, "ET")) }
}
NA_character_
}
parse_start_datetime_numeric <- function(value) {
text <- trimws(as.character(value %||% ""))
if (!nzchar(text)) return(NA_real_)
fmts <- c("%Y-%m-%dT%H:%M:%OSZ", "%Y-%m-%dT%H:%MZ", "%Y-%m-%dT%H:%M:%OS%z", "%Y-%m-%dT%H:%M%z")
for (fmt in fmts) { parsed <- suppressWarnings(as.POSIXct(strptime(text, fmt, tz = "UTC"), tz = "UTC")); if (!is.na(parsed)) return(as.numeric(parsed)) }
parsed_fallback <- suppressWarnings(as.POSIXct(text, tz = "UTC"))
if (!is.na(parsed_fallback)) return(as.numeric(parsed_fallback))
NA_real_
}
parse_time_label_minutes <- function(value) {
text <- toupper(trimws(as.character(value %||% "")))
if (!nzchar(text)) return(NA_real_)
m <- regexec("([0-9]{1,2}):([0-9]{2})\\s*([AP]M)", text, perl = TRUE)
parts <- regmatches(text, m)[[1]]
if (length(parts) != 4) return(NA_real_)
hour <- suppressWarnings(as.integer(parts[2])); minute <- suppressWarnings(as.integer(parts[3])); ampm <- parts[4]
if (is.na(hour) || is.na(minute)) return(NA_real_)
hour <- hour %% 12L; if (identical(ampm, "PM")) hour <- hour + 12L
as.numeric(hour * 60L + minute)
}
extract_scoreboard_team_name <- function(competitor, fallback = "Team") {
team <- competitor$team %||% list()
candidates <- c(as.character(team$shortDisplayName %||% NA_character_), as.character(team$location %||% NA_character_),
as.character(team$name %||% NA_character_), as.character(team$displayName %||% NA_character_))
candidates <- trimws(candidates); candidates <- candidates[!is.na(candidates) & nzchar(candidates)]
if (length(candidates) == 0) return(as.character(fallback))
candidates[[1]]
}
sanitize_scoreboard_record <- function(value) {
text <- trimws(as.character(value %||% ""))
if (!nzchar(text)) return(NA_character_)
m <- regexpr("[0-9]+-[0-9]+(?:-[0-9]+)?", text, perl = TRUE)
if (!is.na(m[[1]]) && m[[1]] != -1) return(regmatches(text, m)[[1]])
text
}
extract_scoreboard_record <- function(competitor) {
records <- competitor$records %||% list()
if (length(records) == 0) return(NA_character_)
record_value <- function(record_entry) {
summary <- as.character(record_entry$summary %||% record_entry$displayValue %||% record_entry$value %||% NA_character_)
summary <- sanitize_scoreboard_record(summary)
if (!is.na(summary) && nzchar(summary)) return(summary)
wins <- suppressWarnings(as.integer(record_entry$wins %||% NA_integer_))
losses <- suppressWarnings(as.integer(record_entry$losses %||% NA_integer_))
if (!is.na(wins) && !is.na(losses)) return(sprintf("%d-%d", wins, losses))
NA_character_
}
overall_idx <- which(vapply(records, function(rec) {
rec_name <- tolower(trimws(as.character(rec$name %||% rec$type %||% rec$description %||% "")))
grepl("overall|total|season", rec_name)
}, logical(1)))
if (length(overall_idx) > 0) { for (idx in overall_idx) { value <- record_value(records[[idx]]); if (!is.na(value) && nzchar(value)) return(value) } }
for (rec in records) { value <- record_value(rec); if (!is.na(value) && nzchar(value)) return(value) }
NA_character_
}
normalize_conference_name <- function(raw) {
if (is.na(raw) || !nzchar(raw)) return(NA_character_)
key <- tolower(gsub("[^a-z0-9]", "", raw))
lookup <- list(
"acc"="ACC", "atlanticcoastconference"="ACC", "atlanticcoast"="ACC",
"americaeast"="America East", "americaeastconference"="America East",
"americanathletic"="American Athletic", "americanathleticconference"="American Athletic", "aac"="American Athletic",
"atlanticsun"="ASUN", "atlanticsunconference"="ASUN", "asun"="ASUN",
"atlantic10"="Atlantic 10", "atlantic10conference"="Atlantic 10", "a10"="Atlantic 10",
"big12"="Big 12", "big12conference"="Big 12",
"bigeast"="Big East", "bigeastconference"="Big East",
"bigsouth"="Big South", "bigsouthconference"="Big South",
"bigten"="Big Ten", "bigtenconference"="Big Ten",
"bigwest"="Big West", "bigwestconference"="Big West",
"colonialathleticassociation"="CAA", "caa"="CAA",
"conferenceusa"="Conference USA", "cusa"="Conference USA",
"horizonleague"="Horizon League",
"ivyleague"="Ivy League",
"metroatlanticathletic"="MAAC", "metroatlanticathleticconference"="MAAC", "maac"="MAAC",
"midamerican"="MAC", "midamericanconference"="MAC", "mac"="MAC",
"mideasternathletic"="MEAC", "mideasternathleticconference"="MEAC", "meac"="MEAC",
"missourivalley"="Missouri Valley", "missourivalleyconference"="Missouri Valley", "mvc"="Missouri Valley",
"mountainwest"="Mountain West", "mountainwestconference"="Mountain West", "mwc"="Mountain West",
"ncaadivisioniindependents"="Independents", "divisioniindependents"="Independents", "independents"="Independents",
"northeast"="NEC", "northeastconference"="NEC", "nec"="NEC",
"ohiovalley"="Ohio Valley", "ohiovalleyconference"="Ohio Valley", "ovc"="Ohio Valley",
"pac12"="Pac-12", "pac12conference"="Pac-12",
"patriotleague"="Patriot League",
"sec"="SEC", "southeasternconference"="SEC", "southeastern"="SEC",
"southern"="Southern", "southernconference"="Southern", "socon"="Southern",
"southland"="Southland", "southlandconference"="Southland",
"southwesternathletic"="SWAC", "southwesternathleticconference"="SWAC", "swac"="SWAC",
"summitleague"="Summit League",
"sunbelt"="Sun Belt", "sunbeltconference"="Sun Belt",
"westcoast"="WCC", "westcoastconference"="WCC", "wcc"="WCC",
"westernathletic"="WAC", "westernathleticconference"="WAC", "wac"="WAC"
)
result <- lookup[[key]]
if (!is.null(result)) return(result)
# Fallback: return cleaned-up version of raw
raw <- gsub("-", " ", raw)
raw <- gsub("\\b(\\w)", "\\U\\1", raw, perl = TRUE)
trimws(raw)
}
parse_espn_score_event <- function(event) {
competition <- event$competitions[[1]] %||% list()
competitors <- competition$competitors %||% list()
away <- purrr::detect(competitors, ~ identical(.x$homeAway, "away")) %||% list()
home <- purrr::detect(competitors, ~ identical(.x$homeAway, "home")) %||% list()
status <- event$status$type %||% competition$status$type %||% list()
away_score <- suppressWarnings(as.integer(away$score %||% NA_character_))
home_score <- suppressWarnings(as.integer(home$score %||% NA_character_))
inning <- suppressWarnings(as.integer(event$status$period %||% competition$status$period %||% NA_integer_))
away_rank <- extract_competitor_rank(away); home_rank <- extract_competitor_rank(home)
away_logo <- extract_logo_url(away); home_logo <- extract_logo_url(home)
location <- extract_game_location(competition, event)
broadcast <- extract_broadcast(competition)
# Extract conference from home team's groups/conferenceId
home_conf <- NA_character_
home_groups <- home$team$groups %||% list()
if (is.list(home_groups) && length(home_groups) > 0) {
home_conf <- as.character(home_groups$name %||% home_groups$shortName %||% NA_character_)
}
if (is.na(home_conf) || !nzchar(home_conf)) {
home_conf <- as.character(home$team$conferenceId %||% NA_character_)
}
away_conf <- NA_character_
away_groups <- away$team$groups %||% list()
if (is.list(away_groups) && length(away_groups) > 0) {
away_conf <- as.character(away_groups$name %||% away_groups$shortName %||% NA_character_)
}
if (is.na(away_conf) || !nzchar(away_conf)) {
away_conf <- as.character(away$team$conferenceId %||% NA_character_)
}
# Extract conference β€” try multiple sources
conf_raw <- NA_character_
# Source 1: competition groups
comp_groups <- competition$groups %||% list()
if (is.list(comp_groups) && length(comp_groups) > 0) {
conf_raw <- as.character(comp_groups$name %||% comp_groups$shortName %||% NA_character_)
}
# Source 2: home team groups
if (is.na(conf_raw) || !nzchar(conf_raw)) {
home_groups <- home$team$groups %||% list()
if (is.list(home_groups) && length(home_groups) > 0) {
conf_raw <- as.character(home_groups$name %||% home_groups$shortName %||% NA_character_)
}
}
# Source 3: home team conferenceId
if (is.na(conf_raw) || !nzchar(conf_raw)) {
conf_raw <- as.character(home$team$conferenceId %||% NA_character_)
}
# Source 4: event-level groups
if (is.na(conf_raw) || !nzchar(conf_raw)) {
evt_groups <- event$groups %||% list()
if (is.list(evt_groups) && length(evt_groups) > 0) {
conf_raw <- as.character(evt_groups$name %||% evt_groups$shortName %||% NA_character_)
}
}
conference <- normalize_conference_name(conf_raw)
conference <- normalize_conference_name(home_conf)
top25_game <- (!is.na(away_rank) && away_rank <= 25L) || (!is.na(home_rank) && home_rank <= 25L)
away_school <- extract_scoreboard_team_name(away, fallback = away$team$displayName %||% "Away")
home_school <- extract_scoreboard_team_name(home, fallback = home$team$displayName %||% "Home")
away_record <- extract_scoreboard_record(away); home_record <- extract_scoreboard_record(home)
away_name <- away$team$displayName %||% away$team$shortDisplayName %||% "Away"
home_name <- home$team$displayName %||% home$team$shortDisplayName %||% "Home"
status_desc <- as.character(status$description %||% "Scheduled")
status_detail <- as.character(status$detail %||% status_desc)
status_short_detail <- as.character(status$shortDetail %||% competition$status$type$shortDetail %||% status_detail)
start_datetime_utc <- as.character(competition$date %||% event$date %||% NA_character_)
start_time <- extract_game_start_time(status_short_detail, status_detail, competition, event)
start_sort_key <- parse_start_datetime_numeric(start_datetime_utc)
start_time_minutes <- parse_time_label_minutes(start_time)
status_state <- tolower(as.character(status$state %||% ""))
status_name <- toupper(as.character(status$name %||% ""))
status_blob <- toupper(paste(status_name, status_desc, status_detail, status_short_detail))
canceled <- grepl("CANCEL", status_blob)
live <- identical(status_state, "in") || grepl("\\bLIVE\\b", status_blob)
final <- isTRUE(status$completed) || identical(status_state, "post")
status_category <- dplyr::case_when(canceled ~ "canceled", live ~ "live", final ~ "final", TRUE ~ "upcoming")
winner <- dplyr::case_when(
status_category == "final" & !is.na(away_score) & !is.na(home_score) & away_score > home_score ~ "away",
status_category == "final" & !is.na(away_score) & !is.na(home_score) & home_score > away_score ~ "home",
TRUE ~ NA_character_)
status_display <- dplyr::case_when(
status_category == "live" ~ paste0("LIVE - ", status_detail), status_category == "final" ~ "Final",
status_category == "canceled" ~ "Canceled", TRUE ~ status_detail)
inning_arrow <- dplyr::case_when(
status_category != "live" ~ "",
grepl("\\bTOP\\b|\\bTOP OF\\b", toupper(status_short_detail)) ~ "up",
grepl("\\bBOT\\b|\\bBOTTOM\\b", toupper(status_short_detail)) ~ "down", TRUE ~ "")
inning_display <- dplyr::case_when(
status_category == "final" & is.na(inning) ~ "F", status_category == "final" & inning == 9L ~ "F",
status_category == "final" ~ paste0("F/", inning), status_category == "live" & !is.na(inning) ~ as.character(inning), TRUE ~ "-")
live_late <- status_category == "live" && !is.na(inning) && inning >= 7L
live_close <- status_category == "live" && !is.na(away_score) && !is.na(home_score) && abs(away_score - home_score) <= 2L
tibble::tibble(
game_id = as.character(event$id %||% NA_character_), away = as.character(away_name), home = as.character(home_name),
away_school = as.character(away_school), home_school = as.character(home_school),
away_record = as.character(away_record), home_record = as.character(home_record),
away_rank = away_rank, home_rank = home_rank, away_logo = away_logo, home_logo = home_logo,
location = as.character(location), broadcast = as.character(broadcast), conference = as.character(conference), top25_game = top25_game, away_score = away_score, home_score = home_score,
away_score_display = ifelse(is.na(away_score), "-", as.character(away_score)),
home_score_display = ifelse(is.na(home_score), "-", as.character(home_score)),
status = as.character(status_desc), detail = as.character(status_detail),
start_datetime_utc = as.character(start_datetime_utc), start_time = as.character(start_time),
start_sort_key = as.numeric(start_sort_key), start_time_minutes = as.numeric(start_time_minutes),
status_category = as.character(status_category), status_display = as.character(status_display),
inning_display = as.character(inning_display), inning_arrow = as.character(inning_arrow),
live = status_category == "live", final = status_category == "final",
live_late = live_late, live_close = live_close, winner = winner)
}
fetch_espn_scoreboard <- function(game_date = Sys.Date(), limit = 200) {
date_value <- as.Date(game_date)
if (is.na(date_value)) { empty <- tibble::tibble(); attr(empty, "fetch_error") <- "Invalid date."; return(empty) }
date_key <- format(date_value, "%Y%m%d")
url <- sprintf("https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard?dates=%s&limit=%d", date_key, as.integer(limit))
payload <- tryCatch(jsonlite::fromJSON(url, simplifyVector = FALSE), error = function(e) NULL)
if (is.null(payload)) { empty <- tibble::tibble(); attr(empty, "fetch_error") <- "Could not reach ESPN scoreboard API."; return(empty) }
events <- payload$events %||% list()
if (length(events) == 0) return(tibble::tibble())
games <- purrr::map_dfr(events, parse_espn_score_event)
if (nrow(games) == 0) return(tibble::tibble())
games %>%
dplyr::mutate(
sort_bucket = dplyr::case_when(status_category == "live" ~ 0L, status_category == "upcoming" ~ 1L, status_category == "final" ~ 2L, status_category == "canceled" ~ 3L, TRUE ~ 4L),
start_sort_abs = dplyr::coalesce(as.numeric(start_sort_key), vapply(start_datetime_utc, parse_start_datetime_numeric, numeric(1))),
start_sort_time = dplyr::coalesce(as.numeric(start_time_minutes), vapply(start_time, parse_time_label_minutes, numeric(1))),
start_sort_missing_abs = is.na(start_sort_abs),
start_sort = dplyr::if_else(start_sort_missing_abs, start_sort_time, start_sort_abs),
start_sort = ifelse(is.na(start_sort), Inf, start_sort)
) %>%
dplyr::arrange(sort_bucket, start_sort_missing_abs, start_sort, away, home) %>%
dplyr::select(-sort_bucket, -start_sort_abs, -start_sort_time, -start_sort_missing_abs, -start_sort)
}
fetch_espn_rankings <- function() {
url <- "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/rankings"
payload <- tryCatch(jsonlite::fromJSON(url, simplifyVector = FALSE), error = function(e) NULL)
if (is.null(payload)) return(list(polls = list(), error = "Could not reach ESPN rankings API."))
rankings_list <- payload$rankings %||% list()
if (length(rankings_list) == 0) return(list(polls = list(), error = NULL))
polls <- list()
for (ranking in rankings_list) {
poll_name <- as.character(ranking$name %||% ranking$shortName %||% "Poll")
ranks <- ranking$ranks %||% list()
if (length(ranks) == 0) next
rows <- purrr::map_dfr(ranks, function(r) {
team <- r$team %||% list()
# Use API-provided logo directly β€” DO NOT construct from ID
logo <- NA_character_
if (is.list(team$logos) && length(team$logos) > 0) {
logo <- as.character(team$logos[[1]]$href %||% team$logos[[1]]$url %||% NA_character_)
}
tibble::tibble(
current_rank = as.integer(r$current %||% NA_integer_),
previous_rank = as.integer(r$previous %||% NA_integer_),
team_name = as.character(team$location %||% team$shortDisplayName %||% team$displayName %||% "Unknown"),
team_full = as.character(team$displayName %||% team$name %||% "Unknown"),
logo = as.character(logo),
record = as.character(r$recordSummary %||% NA_character_),
points = as.integer(r$points %||% NA_integer_),
first_place_votes = as.integer(r$firstPlaceVotes %||% NA_integer_)
)
})
polls[[poll_name]] <- rows
}
list(polls = polls, error = NULL)
}
# ═══════════════════════════════════════════════════════════════════════════════
# TICKER CHIP HTML β€” compact score chip for the scrolling ticker
# ═══════════════════════════════════════════════════════════════════════════════
is_coastal_game <- function(game) {
coastal_patterns <- c("coastal carolina", "coastal caro", "chanticleers", "ccu")
away_key <- tolower(paste(game$away, game$away_school))
home_key <- tolower(paste(game$home, game$home_school))
any(vapply(coastal_patterns, function(p) grepl(p, away_key, fixed = TRUE) || grepl(p, home_key, fixed = TRUE), logical(1)))
}
render_ticker_chip <- function(game) {
is_coastal <- is_coastal_game(game)
# Status text
if (game$status_category == "live") {
arrow <- if (game$inning_arrow == "up") "\u25B2" else if (game$inning_arrow == "down") "\u25BC" else ""
late <- if (isTRUE(game$live_late)) " LATE" else ""
status_txt <- paste0(arrow, game$inning_display, late)
status_cls <- if (isTRUE(game$live_close)) "ticker-live ticker-close" else "ticker-live"
} else if (game$status_category == "final") {
status_txt <- game$inning_display
status_cls <- "ticker-final"
} else if (game$status_category == "canceled") {
status_txt <- "CAN"
status_cls <- "ticker-canceled"
} else {
status_txt <- if (!is.na(game$start_time)) game$start_time else "TBD"
status_cls <- "ticker-upcoming"
}
# Rank prefixes
away_rk <- if (!is.na(game$away_rank) && game$away_rank <= 25) paste0("#", game$away_rank, " ") else ""
home_rk <- if (!is.na(game$home_rank) && game$home_rank <= 25) paste0("#", game$home_rank, " ") else ""
# Winner styling
away_w <- if (!is.na(game$winner) && game$winner == "away") " tw" else if (!is.na(game$winner) && game$winner == "home") " tl" else ""
home_w <- if (!is.na(game$winner) && game$winner == "home") " tw" else if (!is.na(game$winner) && game$winner == "away") " tl" else ""
# Chip class
chip_cls <- if (is_coastal) "tc tcc" else if (isTRUE(game$top25_game)) "tc tc25" else "tc"
tv_html <- ""
if (!is.na(game$broadcast) && nzchar(game$broadcast)) {
tv_html <- sprintf('<div class="ticker-tv">%s</div>', htmltools::htmlEscape(game$broadcast))
}
sprintf('<div class="%s"><span class="ts %s">%s</span><div class="tr"><span class="tn%s">%s%s</span><span class="tsc">%s</span></div><div class="tr"><span class="tn%s">%s%s</span><span class="tsc">%s</span></div></div>',
chip_cls, status_cls, status_txt,
away_w, away_rk, htmltools::htmlEscape(game$away_school), game$away_score_display,
home_w, home_rk, htmltools::htmlEscape(game$home_school), game$home_score_display,
tv_html)
}
# ═══════════════════════════════════════════════════════════════════════════════
# FULL GAME CARD HTML (for the Scores tab)
# ═══════════════════════════════════════════════════════════════════════════════
render_game_card <- function(game) {
badge_color <- switch(
game$status_category,
"live" = "#e74c3c",
"final" = "darkcyan",
"upcoming" = "peru",
"canceled" = "#999",
"#aaa"
)
arrow_icon <- ""
if (identical(game$status_category, "live") &&
!is.na(game$inning_arrow) && nzchar(game$inning_arrow)) {
if (identical(game$inning_arrow, "up")) arrow_icon <- "\u25B2 "
else if (identical(game$inning_arrow, "down")) arrow_icon <- "\u25BC "
}
late_tag <- if (isTRUE(game$live_late)) ' <span style="background:#ff9800;color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;margin-left:6px;font-weight:700;">LATE</span>' else ""
close_tag <- if (isTRUE(game$live_close)) ' <span style="background:#9c27b0;color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;margin-left:4px;font-weight:700;">CLOSE</span>' else ""
badge_text <- switch(
game$status_category,
"live" = paste0("LIVE - ", arrow_icon, game$inning_display, late_tag, close_tag),
"final" = "FINAL",
"upcoming" = game$time_display %||% "UPCOMING",
"canceled" = "CANCELED",
"STATUS"
)
away_rank_label <- if (!is.na(game$away_rank) && game$away_rank <= 25) paste0("#", game$away_rank, " ") else ""
home_rank_label <- if (!is.na(game$home_rank) && game$home_rank <= 25) paste0("#", game$home_rank, " ") else ""
away_weight <- if (!is.na(game$winner) && identical(game$winner, "away")) "800" else "600"
home_weight <- if (!is.na(game$winner) && identical(game$winner, "home")) "800" else "600"
away_opacity <- if (!is.na(game$winner) && identical(game$winner, "home")) "0.55" else "1"
home_opacity <- if (!is.na(game$winner) && identical(game$winner, "away")) "0.55" else "1"
away_logo <- if (!is.na(game$away_logo) && nzchar(game$away_logo)) game$away_logo else "https://a.espncdn.com/i/teamlogos/default-team-logo-500.png"
home_logo <- if (!is.na(game$home_logo) && nzchar(game$home_logo)) game$home_logo else "https://a.espncdn.com/i/teamlogos/default-team-logo-500.png"
away_rec <- if (!is.na(game$away_record) && nzchar(game$away_record)) {
paste0('<span style="font-size:11px;color:#888;margin-left:4px;">(', htmltools::htmlEscape(game$away_record), ')</span>')
} else ""
home_rec <- if (!is.na(game$home_record) && nzchar(game$home_record)) {
paste0('<span style="font-size:11px;color:#888;margin-left:4px;">(', htmltools::htmlEscape(game$home_record), ')</span>')
} else ""
top25_border <- if (isTRUE(game$top25_game)) "border-left:4px solid #FFD700;" else ""
loc_html <- if (!is.na(game$location) && nzchar(game$location)) {
paste0(
'<div style="font-size:11px;color:#999;text-align:center;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">\U0001F4CD ',
htmltools::htmlEscape(game$location),
"</div>"
)
} else ""
tv_html <- if (!is.na(game$broadcast) && nzchar(game$broadcast)) {
paste0(
'<div style="font-size:11px;color:#555;text-align:center;margin-top:4px;font-weight:600;">\U0001F4FA ',
htmltools::htmlEscape(game$broadcast),
"</div>"
)
} else ""
away_school <- htmltools::htmlEscape(game$away_school %||% "")
home_school <- htmltools::htmlEscape(game$home_school %||% "")
away_score <- game$away_score_display %||% ""
home_score <- game$home_score_display %||% ""
sprintf(
paste0(
'<div style="background:#fff;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,.07);padding:16px 18px;%s transition:.2s;border:1px solid #e8e8e8;">',
'<div style="text-align:center;margin-bottom:12px;"><span style="background:%s;color:#fff;font-size:12px;font-weight:700;padding:4px 14px;border-radius:20px;letter-spacing:.3px;">%s</span></div>',
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;opacity:%s;">',
' <div style="display:flex;align-items:center;gap:10px;min-width:0;flex:1;">',
' <img src="%s" style="width:36px;height:36px;object-fit:contain;flex-shrink:0;" onerror="this.style.display=\\\'none\\\'">',
' <div style="min-width:0;"><span style="font-weight:%s;font-size:15px;color:#222;">%s%s</span>%s</div>',
' </div>',
' <span style="font-size:22px;font-weight:%s;color:#222;min-width:28px;text-align:right;">%s</span>',
"</div>",
'<div style="border-top:1px solid #eee;margin:2px 0;"></div>',
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;opacity:%s;">',
' <div style="display:flex;align-items:center;gap:10px;min-width:0;flex:1;">',
' <img src="%s" style="width:36px;height:36px;object-fit:contain;flex-shrink:0;" onerror="this.style.display=\\\'none\\\'">',
' <div style="min-width:0;"><span style="font-weight:%s;font-size:15px;color:#222;">%s%s</span>%s</div>',
" </div>",
' <span style="font-size:22px;font-weight:%s;color:#222;min-width:28px;text-align:right;">%s</span>',
"</div>",
"%s%s</div>"
),
top25_border,
badge_color, badge_text,
away_opacity, away_logo, away_weight, away_rank_label, away_school, away_rec, away_weight, away_score,
home_opacity, home_logo, home_weight, home_rank_label, home_school, home_rec, home_weight, home_score,
loc_html, tv_html
)
}
# ═══════════════════════════════════════════════════════════════════════════════
# BIO DATA
# ═══════════════════════════════════════════════════════════════════════════════
bio_data <- list(
"kevin_schnall"=list(name="Kevin Schnall",title="Head Coach",hometown="Mercerville, NJ",email="kschnall@coastal.edu",seasons="23rd Season at CCU",headshot="https://i.imgur.com/wFYEU7i.png",state="https://i.imgur.com/EjiyqTJ.png",bio="Kevin Schnall is in his 23rd season as head coach of the Coastal Carolina Chanticleers. Under his leadership, the program has become one of the premier baseball programs in the nation. Schnall has guided CCU to multiple NCAA Tournament appearances and has been instrumental in developing numerous professional players. His dedication to excellence both on and off the field has made him one of the most respected coaches in college baseball.",achievements=c("Multiple NCAA Tournament appearances","Conference Championships","Over 100 players drafted or signed professionally","Regional Coach of the Year honors"),additional_images=c("https://i.imgur.com/StNxdqy.jpeg","https://i.imgur.com/kuFY1O6.jpeg","https://i.imgur.com/PbE0ZC7.jpeg","https://i.imgur.com/6n8bwIe.jpeg","https://i.imgur.com/vThNZbX.jpeg","https://i.imgur.com/jxYXOmH.jpeg")),
"chad_oxendine"=list(name="Chad Oxendine",title="Associate Head Coach/Recruiting Coordinator",hometown="Rowland, NC",email="coxendi1@coastal.edu",seasons="7th Season at CCU",headshot="https://i.imgur.com/lm8wvrg.png",state="https://i.imgur.com/opsbD9b.png",bio="Chad Oxendine brings a wealth of experience to the Chanticleers as Associate Head Coach and Recruiting Coordinator.",achievements=c("Led successful recruiting classes","Developed multiple professional prospects","Expert in player development","Strong relationships throughout the Southeast"),additional_images=c("https://i.imgur.com/iZTWJP2.jpeg","https://i.imgur.com/gjecoVq.jpeg","https://i.imgur.com/9HzXyHh.jpeg")),
"matt_williams"=list(name="Matt Williams",title="Pitching Coach/Assistant Coach",hometown="Lancaster, SC",email="mwilliam@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/gHKxJG6.png",state="https://i.imgur.com/r6bmiwL.png",bio="Matt Williams joins the Chanticleers as Pitching Coach, bringing extensive knowledge of modern pitching development and analytics.",achievements=c("Expert in modern pitching mechanics","Advanced analytics implementation","Pitcher development specialist","Technology integration in training"),additional_images=c("https://i.imgur.com/eR47EBM.jpeg","https://i.imgur.com/OhhRM0n.jpeg")),
"matt_schilling"=list(name="Matt Schilling",title="Assistant Coach",hometown="Hightstown, NJ",email="schill4@coastal.edu",seasons="19th Season at CCU",headshot="https://i.imgur.com/FZwk9mY.png",state="https://i.imgur.com/EjiyqTJ.png",bio="Matt Schilling is a veteran member of the Coastal Carolina coaching staff, bringing nearly two decades of experience to the program.",achievements=c("19 seasons of dedicated service","Extensive game management experience","Player development expert","Recruiting specialist"),additional_images=c("https://i.imgur.com/TW5RTYK.jpeg","https://i.imgur.com/MZTHy2h.jpeg")),
"tyler_shewmaker"=list(name="Tyler Shewmaker",title="Director of Player Development and Recruiting",hometown="Corydon, IN",email="tshewmake@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/3XXglGU.png",state="https://i.imgur.com/7OpSWWW.png",bio="Tyler Shewmaker serves as the Director of Player Development and Recruiting.",achievements=c("Comprehensive player development programs","Successful recruiting initiatives","Leadership in program growth","Student-athlete mentoring"),additional_images=c("https://i.imgur.com/3N0r7m9.jpeg")),
"mickey_beach"=list(name="Mickey Beach",title="Director of Baseball Operations",hometown="Newfield, NY",email="mdbeach@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/xu9e05k.png",state="https://i.imgur.com/SMIPTGz.png",bio="Mickey Beach oversees the day-to-day operations of the Coastal Carolina baseball program.",achievements=c("Operational excellence","Program organization","Administrative leadership","Support staff coordination"),additional_images=c("https://i.imgur.com/mOINtir.jpeg")),
"matt_pepin"=list(name="Matt Pepin",title="Director of Analytics",hometown="New Fairfield, CT",email="mcpepin@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/QTf3IhZ.jpeg",state="https://i.imgur.com/dLuJQmF.png",bio="Matt Pepin leads the Chanticleers' analytics department, bringing cutting-edge data analysis to all aspects of the program.",achievements=c("Advanced analytics implementation","Data-driven decision making","Student analyst development","Technology integration"),additional_images=c("https://i.imgur.com/l2vrryw.jpeg")),
"connor_ownings"=list(name="Connor Ownings",title="Assistant Director of Player Development & Recruiting",hometown="Gilbert, SC",email="chowings@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/UMHNSuD.png",state="https://i.imgur.com/r6bmiwL.png",bio="Connor Ownings serves as Assistant Director of Player Development and Recruiting.",achievements=c("Local recruiting expertise","Player development support","Relationship building","Program support"),additional_images=c("https://i.imgur.com/DDWNKwL.jpeg")),
"dylan_eskew"=list(name="Dylan Eskew",title="Director of Pitching Development",hometown="Tampa, FL",email="deskew@coastal.edu",seasons="1st Season at CCU",headshot="https://i.imgur.com/HxhKUfD.jpeg",state="https://i.imgur.com/3brreip.png",bio="Dylan Eskew enters his 1st season on staff as the Director of Pitching Development. Dylan was drafted in the 24th round of the 2019 MLB First-Year Player Draft by the Arizona Diamondbacks.",achievements=c("Pitching biomechanics expert","Modern development techniques","Technology-driven approach","Individual pitcher optimization"),additional_images=c("https://i.imgur.com/82lFWoR.jpeg")),
"mike_thomson"=list(name="Mike Thomson",title="Assistant Director for Speed, Strength and Conditioning",hometown="Yorba Linda, CA",email="mthomson@coastal.edu",seasons="3rd Season at CCU",headshot="https://i.imgur.com/C2OVEbr.jpeg",state="https://i.imgur.com/F5WhI6N.png",bio="Mike Thomson oversees the strength and conditioning program for Coastal Carolina baseball.",achievements=c("Sports science expertise","Injury prevention programs","Performance optimization","Individualized training plans"),additional_images=c("https://i.imgur.com/F9fvhFj.jpeg","https://i.imgur.com/UYmua5A.jpeg")),
"tanner_costine"=list(name="Tanner Costine",title="Athletic Trainer",hometown="Raleigh, NC",email="",seasons="1st Season at CCU",headshot="https://i.imgur.com/N1sb0sa.png",state="https://i.imgur.com/opsbD9b.png",bio="Tanner Costine joins the Chanticleers as Athletic Trainer.",achievements=c("Sports medicine expertise","Injury rehabilitation","Prevention programs","Player health management"),additional_images=character(0)),
"jordan_bowman"=list(name="Jordan Bowman",title="Equipment Manager",hometown="Gamewell, NC",email="jdbowman1@coastal.edu",seasons="2nd Season at CCU",headshot="https://i.imgur.com/dv3CJuO.jpeg",state="https://i.imgur.com/opsbD9b.png",bio="Jordan Bowman enters his 2nd season as Equipment Manager.",achievements=c("Equipment management excellence","Organizational leadership","Operational support","Team preparation"),additional_images=c("https://i.imgur.com/Vjafx8B.jpeg","https://i.imgur.com/sUUBPSl.jpeg")),
"megan_gregory"=list(name="Megan Gregory",title="Senior Academic Advisor",hometown="",email="magregor@coastal.edu",seasons="",headshot="https://i.imgur.com/zzPijj0.jpeg",state="",bio="Megan Gregory 'The Head Coach of Academics' per Coach Schnall, handles all of the academics within our baseball program. Under Megan's leadership, the team achieved a 3.578 GPA during the Spring of 2025, the highest recorded GPA in team history.",achievements=c("Academic excellence support","Student-athlete mentoring","Eligibility management","Career preparation guidance"),additional_images=character(0)),
"mike_cruise"=list(name="Mike Cruise",title="Mental Performance Coach",hometown="",email="mcruise@coastal.edu",seasons="",headshot="https://i.imgur.com/5hm1aYO.png",state="",bio="Mike Cruise works closely with our baseball program as the Mental Performance Coach, training student athlete's mentality.",achievements=c("Sports psychology expertise","Mental performance training","Peak performance development","Stress management techniques"),additional_images=character(0)),
"josh_finklea"=list(name="Josh Finklea",title="Lead Pastor",hometown="",email="jfinklea@coastal.edu",seasons="",headshot="https://i.imgur.com/Og3AgC9.png",state="",bio="Josh Finklea has served as the Lead Pastor at The Rock Church since 2015. As a former college baseball player, Fink serves as a resource for Coastal Carolina athletes.",achievements=c("Spiritual guidance","Character development","Life counseling","Community building"),additional_images=c("https://i.imgur.com/AcPJOgF.jpeg"))
)
# ═══════════════════════════════════════════════════════════════════════════════
# UI
# ═══════════════════════════════════════════════════════════════════════════════
ui <- fluidPage(
shinyjs::useShinyjs(),
tags$head(
tags$meta(name="viewport",content="width=device-width, initial-scale=1"),
tags$style(HTML("
:root{ --brand:darkcyan; --brand-2:peru; --bg:#f8f9fa; --text:#111; --muted:#666; --card:#fff; --border:#e6e6e6; }
*{box-sizing:border-box;}
html,body{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;letter-spacing:.1px;}
a{text-decoration:none;}
/* ── TICKER (MINIMAL WHITE) ────────────────────────────── */
.scores-ticker-wrap{
max-width:1200px; margin:0 auto; overflow-x:auto; overflow-y:hidden;
background:#fff; border-bottom:1px solid #e0e0e0;
box-shadow:0 1px 3px rgba(0,0,0,.04);
-webkit-overflow-scrolling:touch;
}
.scores-ticker-wrap::-webkit-scrollbar{height:3px;}
.scores-ticker-wrap::-webkit-scrollbar-track{background:transparent;}
.scores-ticker-wrap::-webkit-scrollbar-thumb{background:#ccc;border-radius:2px;}
.scores-ticker-track{display:flex;align-items:center;gap:0;padding:4px 8px;width:max-content;}
.tc{flex-shrink:0;background:transparent;border-right:1px solid #eee;border-radius:0;padding:5px 12px;min-width:145px;}
.tcc{background:rgba(0,139,139,.04)!important;border-right-color:darkcyan!important;}
.tc25{background:rgba(255,215,0,.04)!important;}
.ts{display:block;text-align:center;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;margin-bottom:2px;}
.ticker-live{color:#e74c3c;}
.ticker-final{color:#aaa;}
.ticker-upcoming{color:var(--brand);}
.ticker-canceled{color:#bbb;}
.ticker-close{color:#9c27b0!important;}
.tr{display:flex;align-items:center;justify-content:space-between;gap:6px;}
.tn{font-size:11px;font-weight:600;color:#444;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px;}
.tw{font-weight:800!important;color:#111!important;}
.tl{opacity:.45;}
.tsc{font-size:12px;font-weight:800;color:#222;min-width:16px;text-align:right;}
.ticker-divider{flex-shrink:0;width:0;height:0;}
.ticker-view-all{
flex-shrink:0;background:var(--brand);color:#fff;border:none;border-radius:5px;
padding:5px 12px;font-size:10px;font-weight:700;cursor:pointer;white-space:nowrap;
letter-spacing:.3px;text-transform:uppercase;transition:opacity .2s;margin-left:4px;
}
.ticker-view-all:hover{opacity:.85;}
.ticker-empty{color:#999;font-size:12px;font-weight:600;padding:0 20px;white-space:nowrap;}
/* ── TABS ──────────────────────────────────────────────── */
.teal-tabs{max-width:1200px;margin:16px auto 8px;}
.teal-tabs .nav-tabs,.teal-tabs .nav.nav-tabs{border:none;border-radius:50px;padding:6px 12px;margin:20px auto 0;max-width:100%;background:linear-gradient(135deg,#d4edeb 0%,#e8ddd0 50%,#d4edeb 100%);box-shadow:0 4px 16px rgba(0,139,139,.12),inset 0 2px 4px rgba(255,255,255,.6);border:1px solid rgba(0,139,139,.2);position:relative;overflow-x:auto;-webkit-overflow-scrolling:touch;display:flex;justify-content:center;align-items:center;flex-wrap:wrap;gap:6px;}
.teal-tabs .nav-tabs::-webkit-scrollbar{height:0;}
.teal-tabs .nav-tabs::before{content:'';position:absolute;inset:0;pointer-events:none;border-radius:50px;background:linear-gradient(135deg,rgba(255,255,255,.4),transparent);}
.teal-tabs .nav-tabs .nav-link{color:var(--brand);border:none!important;border-radius:50px;background:transparent;font-weight:700;font-size:14.5px;padding:10px 22px;white-space:nowrap;letter-spacing:.2px;transition:all .2s ease;}
.teal-tabs .nav-tabs>li>a{color:var(--brand)!important;border:none!important;border-radius:50px!important;background:transparent!important;font-weight:700;font-size:14.5px;padding:10px 22px;white-space:nowrap;letter-spacing:.2px;transition:all .2s ease;}
.teal-tabs .nav-tabs .nav-link:hover,.teal-tabs .nav-tabs>li>a:hover{color:#006666!important;background:rgba(255,255,255,.5)!important;transform:translateY(-1px);}
.teal-tabs .nav-tabs .nav-link.active{background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important;color:#fff!important;text-shadow:0 1px 2px rgba(0,0,0,.2);box-shadow:0 4px 16px rgba(0,139,139,.4),inset 0 2px 8px rgba(255,255,255,.4),inset 0 -2px 6px rgba(0,0,0,.2);border:1px solid rgba(255,255,255,.3);}
.teal-tabs .nav-tabs>li.active>a,.teal-tabs .nav-tabs>li.active>a:focus,.teal-tabs .nav-tabs>li.active>a:hover{background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important;color:#fff!important;text-shadow:0 1px 2px rgba(0,0,0,.2);box-shadow:0 4px 16px rgba(0,139,139,.4),inset 0 2px 8px rgba(255,255,255,.4),inset 0 -2px 6px rgba(0,0,0,.2);border:1px solid rgba(255,255,255,.3)!important;}
.teal-tabs .tab-content{background:linear-gradient(135deg,rgba(255,255,255,.95),rgba(248,249,250,.95));border-radius:20px;padding:30px;margin-top:18px;box-shadow:0 15px 40px rgba(0,139,139,.1);backdrop-filter:blur(15px);border:1px solid rgba(0,139,139,.1);position:relative;overflow:hidden;}
.teal-tabs .tab-content::before{content:'';position:absolute;left:0;right:0;top:0;height:4px;background:linear-gradient(90deg,var(--brand),var(--brand-2),var(--brand));background-size:200% 100%;animation:shimmer 3s linear infinite;}
@keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
.tab-content h1,.tab-content h2,.tab-content h3{color:var(--brand);font-weight:800;letter-spacing:.2px;}
/* ── SCORES TAB ────────────────────────────────────────── */
.scores-controls{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;margin-bottom:24px;padding:12px;background:#fff;border-radius:14px;box-shadow:0 2px 8px rgba(0,0,0,.05);}
.scores-controls label{font-weight:700;color:var(--brand);font-size:14px;margin:0;}
.scores-controls .form-group{margin:0;}
.scores-nav-btn{background:var(--brand);color:#fff;border:none;border-radius:50%;width:36px;height:36px;font-size:18px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.2s;line-height:1;}
.scores-nav-btn:hover{background:var(--brand-2);transform:scale(1.1);}
.scores-date-display{font-size:16px;font-weight:700;color:var(--brand);min-width:200px;text-align:center;}
.scores-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px;}
.scores-section-label{grid-column:1/-1;font-size:14px;font-weight:800;color:var(--brand);text-transform:uppercase;letter-spacing:1px;padding:8px 0 4px;border-bottom:2px solid var(--brand);margin-top:8px;}
.scores-section-label:first-child{margin-top:0;}
.scores-empty{text-align:center;padding:60px 20px;color:var(--muted);}
.scores-empty-icon{font-size:48px;margin-bottom:12px;}
.scores-empty-text{font-size:18px;font-weight:700;color:var(--brand);}
.scores-empty-sub{font-size:14px;margin-top:6px;}
.scores-count{text-align:center;font-size:13px;color:var(--muted);margin-top:16px;}
.scores-filter-row{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;margin-bottom:16px;}
.scores-filter-btn{background:transparent;color:var(--brand);border:2px solid var(--brand);border-radius:20px;padding:6px 16px;font-size:13px;font-weight:700;cursor:pointer;transition:.2s;}
.scores-filter-btn:hover,.scores-filter-btn.active{background:var(--brand);color:#fff;}
/* ── RANKINGS ──────────────────────────────────────────── */
.poll-selector{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;justify-content:center;}
.poll-btn{background:transparent;color:var(--brand);border:2px solid var(--brand);border-radius:20px;padding:8px 20px;font-size:13px;font-weight:700;cursor:pointer;transition:.2s;}
.poll-btn:hover,.poll-btn.active{background:var(--brand);color:#fff;}
.rankings-container{background:#fff;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,.07);overflow:hidden;border:1px solid #e8e8e8;}
.rankings-header{display:grid;grid-template-columns:50px 1fr 90px 70px 70px;padding:12px 16px;background:linear-gradient(135deg,#008b8b,#20b2aa);color:#fff;font-size:12px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;}
.rankings-row{display:grid;grid-template-columns:50px 1fr 90px 70px 70px;padding:10px 16px;border-bottom:1px solid #f0f0f0;align-items:center;transition:background .15s;}
.rankings-row:hover{background:#f0fdfd;}
.rankings-row:last-child{border-bottom:none;}
.rank-num{font-weight:800;color:var(--brand);font-size:17px;text-align:center;}
.rank-team{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px;color:#222;}
.rank-team img{width:30px;height:30px;object-fit:contain;flex-shrink:0;}
.rank-record{font-size:13px;color:var(--muted);text-align:center;}
.rank-trend{text-align:center;font-size:12px;font-weight:700;}
.rank-up{color:#27ae60;}
.rank-down{color:#e74c3c;}
.rank-same{color:#ccc;}
.rank-pts{font-size:13px;color:var(--muted);text-align:center;}
/* ── BIO ────────────────────────────────────────────────── */
.bio-container{max-width:1000px;margin:0 auto;padding:20px;}
.bio-header{text-align:center;margin-bottom:30px;padding:20px;background:var(--card);border-radius:12px;box-shadow:0 4px 8px rgba(0,0,0,.08);}
.bio-headshot{width:200px;height:200px;border-radius:50%;margin:0 auto 15px;object-fit:cover;object-position:top center;border:4px solid var(--brand);}
.bio-name{font-size:3rem;font-weight:800;color:var(--brand);margin-bottom:8px;}
.bio-title{font-size:2rem;color:var(--brand-2);font-style:italic;font-weight:700;margin-bottom:15px;}
.bio-info{font-size:2rem;color:var(--text);line-height:1.4;}
.bio-content{background:var(--card);border-radius:12px;padding:25px;margin-bottom:25px;box-shadow:0 4px 8px rgba(0,0,0,.08);}
.bio-section-title{font-size:1.8rem;font-weight:800;color:var(--brand);margin-bottom:15px;border-bottom:2px solid var(--brand);padding-bottom:8px;}
.bio-text{font-size:1.8rem;line-height:1.6;color:var(--text);margin-bottom:20px;}
.achievements-list{list-style:none;padding:0;}
.achievements-list li{font-size:1.8rem;color:var(--text);margin-bottom:8px;padding-left:20px;position:relative;}
.achievements-list li:before{content:'\\25B8';color:var(--brand);font-weight:bold;position:absolute;left:0;}
.back-btn{background:var(--brand);color:#fff;padding:10px 20px;border:none;border-radius:6px;font-weight:800;cursor:pointer;font-size:1.3rem;margin-bottom:20px;transition:.2s;}
.back-btn:hover{background:var(--brand-2);}
.additional-images{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:20px;margin-top:20px;}
.additional-images img{width:100%;height:220px;object-fit:contain;background:#f5f9f9;border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 2px 4px rgba(0,0,0,.08);}
.headshot{width:120px;height:120px;border-radius:50%;display:block;margin:0 auto 6px;object-fit:cover;object-position:top center;}
.state{width:80px;height:auto;display:block;margin:0 auto 10px;}
.app-container{margin:20px;}
.header-logo{text-align:center;margin-bottom:0;}
.header-logo img{height:80px;width:auto;}
.section-header{color:var(--brand);border-bottom:2px solid var(--brand);padding-bottom:8px;margin:8px 0 12px;}
.apps-section{max-width:1100px;margin:0 auto;padding:10px 0;}
.cards-row{display:grid;grid-template-columns:repeat(3,1fr);gap:28px;margin-bottom:28px;}
.cards-row:last-child{margin-bottom:0;}
.app-card{border:3px solid var(--brand);border-radius:16px;background:var(--card);text-align:center;padding:18px;min-height:300px;box-shadow:0 4px 12px rgba(0,0,0,.08);transition:all .25s ease;cursor:pointer;display:flex;flex-direction:column;justify-content:flex-start;outline:none;}
.app-card:hover,.app-card:focus-visible{transform:translateY(-4px);box-shadow:0 12px 24px rgba(0,0,0,.15);border-color:#006666;background:#f0fdff;}
.app-card img{width:100%;height:160px;object-fit:cover;border-radius:10px;margin-bottom:14px;}
.app-title{font-size:17px;font-weight:800;color:var(--brand);margin-top:auto;padding-top:8px;line-height:1.3;}
.app-description{font-size:13px;color:var(--muted);margin-top:6px;line-height:1.4;padding:0 4px;}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px;padding:8px 0;}
.contact-card{border:2px solid var(--brand);border-radius:12px;padding:16px;background:var(--card);transition:.2s;}
.contact-card:hover{border-color:var(--brand-2);box-shadow:0 4px 8px rgba(0,0,0,.1);}
.contact-name{font-size:18px;font-weight:800;color:var(--brand);}
.contact-title{font-size:14px;color:var(--brand-2);font-style:italic;font-weight:700;}
.contact-info{font-size:13px;color:var(--text);margin-top:4px;}
.view-bio-btn{background:var(--brand);color:#fff;padding:8px 16px;border:none;border-radius:5px;font-size:12px;font-weight:700;cursor:pointer;margin-top:10px;transition:.2s;}
.view-bio-btn:hover{background:var(--brand-2);}
.resource-item{border:1px solid var(--border);border-radius:10px;padding:14px;margin:10px 0;background:var(--card);transition:.2s;}
.resource-item:hover{border-color:var(--brand);box-shadow:0 2px 4px rgba(0,0,0,.1);}
.resource-title{font-size:16px;font-weight:800;color:var(--brand);}
.resource-description{font-size:13px;color:var(--muted);margin-top:4px;}
.resource-item a{color:var(--brand);font-weight:700;}
@media(max-width:992px){.cards-row{grid-template-columns:repeat(2,1fr);gap:24px;}.scores-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));}}
@media(max-width:768px){.teal-tabs .nav-tabs,.teal-tabs .nav.nav-tabs{justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto;}.cards-row{grid-template-columns:1fr;gap:20px;}.app-card{min-height:260px;}.grid{grid-template-columns:1fr;}.scores-grid{grid-template-columns:1fr;}.scores-ticker-wrap{border-radius:0;}}
@media(max-width:480px){.teal-tabs .nav-tabs .nav-link,.teal-tabs .nav-tabs>li>a{font-size:13px;padding:8px 16px;}.bio-name{font-size:2.2rem;}.bio-title{font-size:1.6rem;}.app-card{min-height:240px;padding:14px;}.app-card img{height:140px;}}
")),
tags$script(HTML("
document.addEventListener('keydown',function(e){if(e.key==='Enter'&&e.target.classList.contains('app-card')){var url=e.target.getAttribute('data-url');if(url)window.open(url,'_blank','noopener');}});
"))
),
shinyjs::hidden(textInput("current_page","",value="main")),
# ── MAIN PAGE ──────────────────────────────────────────────────────────────
conditionalPanel(
condition = "input.current_page == 'main'",
# Logo
div(class="header-logo",tags$img(src="https://i.imgur.com/RerzoOW.png",alt="Coastal Carolina logo",loading="lazy")),
# ── SCORES TICKER ──────────────────────────────────────────────────────
uiOutput("scores_ticker"),
# ── TABS ───────────────────────────────────────────────────────────────
div(class="teal-tabs",
tabsetPanel(id="main_tabs",
# OUR APPS
tabPanel("Our Apps", div(class="apps-section",
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/CoastalPitchers",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/CoastalPitchers','_blank','noopener');",tags$img(src="https://i.imgur.com/ExrGcUV.png",alt="Pitching App",loading="lazy"),div(class="app-title","Pitching App"),div(class="app-description","Analyze Pitchers with Metrics, Models, and Data Visualizations")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/CoastalHitters_duplicated",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/CoastalHitters_duplicated','_blank','noopener');",tags$img(src="https://i.imgur.com/M5O9eZv.png",alt="Hitting App",loading="lazy"),div(class="app-title","Hitting App"),div(class="app-description","Analyze Hitters and Defense with Metrics, Models, and Data Visualizations")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/DefensiveAp",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/DefensiveAp','_blank','noopener');",tags$img(src="https://i.imgur.com/BpvbYAC.jpeg",alt="Defensive App",loading="lazy"),div(class="app-title","Defensive App"),div(class="app-description","OAA, Directional OAA, Catching Data Visualizations, Models, and Opponent Positioning"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/AdvancePitcher",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/AdvancePitcher','_blank','noopener');",tags$img(src="https://i.imgur.com/HwU7Ajy.jpeg",alt="Advance Pitcher Scouting",loading="lazy"),div(class="app-title","Advance Pitcher Scouting"),div(class="app-description","Make Notes and Download Full Opposing Pitcher and Bullpen Reports")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/AdvanceHitter",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/AdvanceHitter','_blank','noopener');",tags$img(src="https://i.imgur.com/d19htzM.png",alt="Advance Hitter Scouting",loading="lazy"),div(class="app-title","Advance Hitter Scouting"),div(class="app-description","Analyze and Make Notes on Opposing Hitters, Download Scouting Cards, Matchup Tools")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/DataProcess",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/DataProcess','_blank','noopener');",tags$img(src="https://i.imgur.com/7RtRjmu.png",alt="Data Processing App",loading="lazy"),div(class="app-title","Data Processing App"),div(class="app-description","Upload CSV's, Preview Data, Retag Pitches, Prepare Data for Apps"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/PostgameReports",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/PostgameReports','_blank','noopener');",tags$img(src="https://i.imgur.com/a0uzA25.jpeg",alt="Postgame Report App",loading="lazy"),div(class="app-title","Postgame Report App"),div(class="app-description","Create Coastal and Opposing Postgame Pitching, Hitting, Umpire, and Catching Reports")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://huggingface.co/spaces/CoastalBaseball/Tracking",onclick="window.open('https://huggingface.co/spaces/CoastalBaseball/Tracking','_blank','noopener');",tags$img(src="https://i.imgur.com/Gxy2NtO.jpeg",alt="Tracking App",loading="lazy"),div(class="app-title","Tracking App"),div(class="app-description","Track Intended Zones, Velo, Strike%, and More in Bullpens and Games")),
div(style="visibility:hidden;")
)
)),
# EXTERNAL APPS
tabPanel("External Apps", div(class="apps-section",
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://goccusports.com/sports/baseball/roster",onclick="window.open('https://goccusports.com/sports/baseball/roster','_blank','noopener');",tags$img(src="https://i.imgur.com/6ImerJR.png",alt="Roster",loading="lazy"),div(class="app-title","2026 Coastal Roster")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://goccusports.com/sports/baseball/stats/2025",onclick="window.open('https://goccusports.com/sports/baseball/stats/2025','_blank','noopener');",tags$img(src="https://i.imgur.com/6ImerJR.png",alt="Stats",loading="lazy"),div(class="app-title","2026 Coastal Official Stats")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://goccusports.com/sports/baseball/schedule",onclick="window.open('https://goccusports.com/sports/baseball/schedule','_blank','noopener');",tags$img(src="https://i.imgur.com/6ImerJR.png",alt="Schedule",loading="lazy"),div(class="app-title","2026 Coastal Schedule"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://643charts.com/",onclick="window.open('https://643charts.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/UYfkrmE.png",alt="643 Charts",loading="lazy"),div(class="app-title","643 Charts")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://www.64analytics.com/",onclick="window.open('https://www.64analytics.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/4oaT5OT.png",alt="64 Analytics",loading="lazy"),div(class="app-title","64 Analytics")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://www.pitchaware.com/home",onclick="window.open('https://www.pitchaware.com/home','_blank','noopener');",tags$img(src="https://i.imgur.com/S4CmKO0.png",alt="AWRE",loading="lazy"),div(class="app-title","AWRE"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://d1baseball.com/",onclick="window.open('https://d1baseball.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/1VJymrW.png",alt="D1 Baseball",loading="lazy"),div(class="app-title","D1 Baseball")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://coastalcarolina-ncaabaseball.trumedianetworks.com/baseball/standings?pc=%7B%22bbtlo%22%3A%22D1%22%2C%22by%22%3A2025%7D",onclick="window.open('https://coastalcarolina-ncaabaseball.trumedianetworks.com/baseball/standings?pc=%7B%22bbtlo%22%3A%22D1%22%2C%22by%22%3A2025%7D','_blank','noopener');",tags$img(src="https://i.imgur.com/KnMvH5g.png",alt="NCAA TruMedia",loading="lazy"),div(class="app-title","NCAA TruMedia")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://coastal-mlb.trumedianetworks.com/",onclick="window.open('https://coastal-mlb.trumedianetworks.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/jBxU5NI.png",alt="MLB TruMedia",loading="lazy"),div(class="app-title","MLB TruMedia"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://baseballsavant.mlb.com/",onclick="window.open('https://baseballsavant.mlb.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/BHguPnA.png",alt="Baseball Savant",loading="lazy"),div(class="app-title","Baseball Savant")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://www.prepbaseballreport.com/",onclick="window.open('https://www.prepbaseballreport.com/','_blank','noopener');",tags$img(src="https://i.imgur.com/y3LMhwJ.png",alt="PBR",loading="lazy"),div(class="app-title","PBR: Prep Baseball Report")),
div(class="app-card",tabindex="0",role="link",`data-url`="https://www.perfectgame.org/",onclick="window.open('https://www.perfectgame.org/','_blank','noopener');",tags$img(src="https://i.imgur.com/XuYLnVJ.png",alt="Perfect Game",loading="lazy"),div(class="app-title","Perfect Game"))
),
div(class="cards-row",
div(class="app-card",tabindex="0",role="link",`data-url`="https://www.warrennolan.com/baseball/2026/rpi-live",onclick="window.open('https://www.warrennolan.com/baseball/2026/rpi-live','_blank','noopener');",tags$img(src="https://i.imgur.com/NJtRCMJ.png",alt="Warren Nolan",loading="lazy"),div(class="app-title","Warren Nolan: Live RPI")),
div(style="visibility:hidden;"),div(style="visibility:hidden;")
)
)),
# COACHING STAFF
tabPanel("Coaching Staff", div(class="app-container grid",
h2("Our Coaching Staff",class="section-header",style="grid-column:1/-1;margin-top:6px;"),
div(class="contact-card",tags$img(src="https://i.imgur.com/wFYEU7i.png",class="headshot"),tags$img(src="https://i.imgur.com/EjiyqTJ.png",class="state"),div(class="contact-name","Kevin Schnall"),div(class="contact-title","Head Coach"),div(class="contact-info","Hometown: Mercerville, NJ",br(),"Email: ",tags$a(href="mailto:kschnall@coastal.edu","kschnall@coastal.edu"),br(),"23rd Season at CCU"),actionButton("bio_kevin_schnall","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/lm8wvrg.png",class="headshot"),tags$img(src="https://i.imgur.com/opsbD9b.png",class="state"),div(class="contact-name","Chad Oxendine"),div(class="contact-title","Associate Head Coach/Recruiting Coordinator"),div(class="contact-info","Hometown: Rowland, NC",br(),"Email: ",tags$a(href="mailto:coxendi1@coastal.edu","coxendi1@coastal.edu"),br(),"7th Season at CCU"),actionButton("bio_chad_oxendine","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/gHKxJG6.png",class="headshot"),tags$img(src="https://i.imgur.com/r6bmiwL.png",class="state"),div(class="contact-name","Matt Williams"),div(class="contact-title","Pitching Coach/Assistant Coach"),div(class="contact-info","Hometown: Lancaster, SC",br(),"Email: ",tags$a(href="mailto:mwilliam@coastal.edu","mwilliam@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_matt_williams","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/FZwk9mY.png",class="headshot"),tags$img(src="https://i.imgur.com/EjiyqTJ.png",class="state"),div(class="contact-name","Matt Schilling"),div(class="contact-title","Assistant Coach"),div(class="contact-info","Hometown: Hightstown, NJ",br(),"Email: ",tags$a(href="mailto:schill4@coastal.edu","schill4@coastal.edu"),br(),"19th Season at CCU"),actionButton("bio_matt_schilling","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/3XXglGU.png",class="headshot"),tags$img(src="https://i.imgur.com/7OpSWWW.png",class="state"),div(class="contact-name","Tyler Shewmaker"),div(class="contact-title","Director of Player Development and Recruiting"),div(class="contact-info","Hometown: Corydon, IN",br(),"Email: ",tags$a(href="mailto:tshewmake@coastal.edu","tshewmake@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_tyler_shewmaker","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/xu9e05k.png",class="headshot"),tags$img(src="https://i.imgur.com/SMIPTGz.png",class="state"),div(class="contact-name","Mickey Beach"),div(class="contact-title","Director of Baseball Operations"),div(class="contact-info","Hometown: Newfield, NY",br(),"Email: ",tags$a(href="mailto:mdbeach@coastal.edu","mdbeach@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_mickey_beach","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/QTf3IhZ.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/dLuJQmF.png",class="state"),div(class="contact-name","Matt Pepin"),div(class="contact-title","Director of Analytics"),div(class="contact-info","Hometown: New Fairfield, CT",br(),"Email: ",tags$a(href="mailto:mcpepin@coastal.edu","mcpepin@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_matt_pepin","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/UMHNSuD.png",class="headshot"),tags$img(src="https://i.imgur.com/r6bmiwL.png",class="state"),div(class="contact-name","Connor Ownings"),div(class="contact-title","Assistant Director of Player Development & Recruiting"),div(class="contact-info","Hometown: Gilbert, SC",br(),"Email: ",tags$a(href="mailto:chowings@coastal.edu","chowings@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_connor_ownings","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/HxhKUfD.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/3brreip.png",class="state"),div(class="contact-name","Dylan Eskew"),div(class="contact-title","Director of Pitching Development"),div(class="contact-info","Hometown: Tampa, FL",br(),"Email: ",tags$a(href="mailto:deskew@coastal.edu","deskew@coastal.edu"),br(),"1st Season at CCU"),actionButton("bio_dylan_eskew","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/C2OVEbr.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/F5WhI6N.png",class="state"),div(class="contact-name","Mike Thomson"),div(class="contact-title","Assistant Director for Speed, Strength and Conditioning"),div(class="contact-info","Hometown: Yorba Linda, CA",br(),"Email: ",tags$a(href="mailto:mthomson@coastal.edu","mthomson@coastal.edu"),br(),"3rd Season at CCU"),actionButton("bio_mike_thomson","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/N1sb0sa.png",class="headshot"),tags$img(src="https://i.imgur.com/opsbD9b.png",class="state"),div(class="contact-name","Tanner Costine"),div(class="contact-title","Athletic Trainer"),div(class="contact-info","Hometown: Raleigh, NC",br(),"1st Season at CCU"),actionButton("bio_tanner_costine","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/dv3CJuO.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/opsbD9b.png",class="state"),div(class="contact-name","Jordan Bowman"),div(class="contact-title","Equipment Manager"),div(class="contact-info","Hometown: Gamewell, NC",br(),"Email: ",tags$a(href="mailto:jdbowman1@coastal.edu","jdbowman1@coastal.edu"),br(),"2nd Season at CCU"),actionButton("bio_jordan_bowman","View Bio",class="view-bio-btn"))
)),
# SUPPORT STAFF
tabPanel("Support Staff", div(class="app-container grid",
h2("Our Support Staff",class="section-header",style="grid-column:1/-1;margin-top:6px;"),
div(class="contact-card",tags$img(src="https://i.imgur.com/zzPijj0.jpeg",class="headshot"),div(class="contact-name","Megan Gregory"),div(class="contact-title","Senior Academic Advisor"),div(class="contact-info","Email: ",tags$a(href="mailto:magregor@coastal.edu","magregor@coastal.edu")),actionButton("bio_megan_gregory","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/5hm1aYO.png",class="headshot"),div(class="contact-name","Mike Cruise"),div(class="contact-title","Mental Performance Coach"),div(class="contact-info","Email: ",tags$a(href="mailto:mcruise@coastal.edu","mcruise@coastal.edu")),actionButton("bio_mike_cruise","View Bio",class="view-bio-btn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/Og3AgC9.png",class="headshot"),div(class="contact-name","Josh Finklea"),div(class="contact-title","Lead Pastor"),div(class="contact-info","Email: ",tags$a(href="mailto:jfinklea@coastal.edu","jfinklea@coastal.edu")),actionButton("bio_josh_finklea","View Bio",class="view-bio-btn"))
)),
# ANALYTICS TEAM
tabPanel("Analytics Team", div(class="app-container grid",
h2("Our Analytics Team",class="section-header",style="grid-column:1/-1;margin-top:6px;"),
div(class="contact-card",tags$img(src="https://i.imgur.com/QTf3IhZ.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/dLuJQmF.png",class="state"),div(class="contact-name","Matt Pepin"),div(class="contact-title","Director of Analytics"),div(class="contact-info","Hometown: New Fairfield, CT",br(),"Email: ",tags$a(href="mailto:mcpepin@coastal.edu","mcpepin@coastal.edu"),br(),"2nd Season at CCU"),div(class="contact-title","X: @matthewpepin_")),
div(class="contact-card",tags$img(src="https://i.imgur.com/wL89CjE.jpeg",class="headshot"),tags$img(src="https://i.imgur.com/EjiyqTJ.png",class="state"),div(class="contact-name","Isaac Groffman"),div(class="contact-title","Lead Analytics Manager"),div(class="contact-info","Hometown: Montclair, NJ",br(),"Email: ",tags$a(href="mailto:ingroffma@coastal.edu","ingroffma@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @isaacgroffman")),
div(class="contact-card",tags$img(src="https://i.imgur.com/IKmzp0y.png",class="headshot"),tags$img(src="https://i.imgur.com/r6bmiwL.png",class="state"),div(class="contact-name","Kyle Buckley"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Myrtle Beach, SC",br(),"Email: ",tags$a(href="mailto:kpbuckle@coastal.edu","kpbuckle@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @kylebuckley33")),
div(class="contact-card",tags$img(src="https://i.imgur.com/EjgHuFX.png",class="headshot"),tags$img(src="https://i.imgur.com/opsbD9b.png",class="state"),div(class="contact-name","Alex Papiernik"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Indian Trail, NC",br(),"Email: ",tags$a(href="mailto:atpapiern@coastal.edu","atpapiern@coastal.edu"),br(),"Class: Senior"),div(class="contact-title","X: @Papes704")),
div(class="contact-card",tags$img(src="https://i.imgur.com/L0CUDcN.png",class="headshot"),tags$img(src="https://i.imgur.com/r6bmiwL.png",class="state"),div(class="contact-name","Edison Vicari"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Catawba, SC",br(),"Email: ",tags$a(href="mailto:ejvicari@coastal.edu","ejvicari@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @EdisonVicari")),
div(class="contact-card",tags$img(src="https://i.imgur.com/5xWGtEb.png",class="headshot"),tags$img(src="https://i.imgur.com/OWo31pX.png",class="state"),div(class="contact-name","Zach Meyers"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Manchester, MD",br(),"Email: ",tags$a(href="mailto:zdmeyers@coastal.edu","zdmeyers@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @ZMeyers89")),
div(class="contact-card",tags$img(src="https://i.imgur.com/wKWiysT.png",class="headshot"),tags$img(src="https://i.imgur.com/opsbD9b.png",class="state"),div(class="contact-name","Jack Papiernik"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Indian Trail, NC",br(),"Email: ",tags$a(href="mailto:jmpapiern@coastal.edu","jmpapiern@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @jack_papiernik")),
div(class="contact-card",tags$img(src="https://i.imgur.com/sih7JNF.png",class="headshot"),tags$img(src="https://i.imgur.com/EjiyqTJ.png",class="state"),div(class="contact-name","Alex Soto"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Neptune, NJ",br(),"Email: ",tags$a(href="mailto:amsoto1@coastal.edu","amsoto1@coastal.edu"),br(),"Class: Senior"),div(class="contact-title","X: @SotoBaseball27")),
div(class="contact-card",tags$img(src="https://i.imgur.com/ZLbTdSv.png",class="state"),div(class="contact-name","Jake Jensen"),div(class="contact-title","Analytics Manager"),div(class="contact-info","Hometown: Parkesburg, PA",br(),"Email: ",tags$a(href="mailto:jdjensen1@coastal.edu","jdjensen1@coastal.edu"),br(),"Class: Junior"),div(class="contact-title","X: @JakeJensennn")),
div(class="contact-card",tags$img(src="https://i.imgur.com/dLuJQmF.png",class="state"),div(class="contact-name","Jameson Bodenburg"),div(class="contact-title","Remote Research and Development Analyst"),div(class="contact-info","Hometown: Simsbury, CT",br(),"Email: ",tags$a(href="mailto:jgbodenb@syr.edu","jgbodenb@syr.edu"),br(),"Class: Senior (Syracuse University)"),div(class="contact-title","X: @JBodenburg1")),
div(class="contact-card",tags$img(src="https://i.imgur.com/SMIPTGz.png",class="state"),div(class="contact-name","Owen St. Onge"),div(class="contact-title","Remote Research and Development Analyst"),div(class="contact-info","Hometown: Poughkeepsie, NY",br(),"Email: ",tags$a(href="mailto:owstonge@syr.edu","owstonge@syr.edu"),br(),"Class: Senior (Syracuse University)"),div(class="contact-title","X: @owen_stonge")),
div(class="contact-card",tags$img(src="https://i.imgur.com/ROxRV1J.png",class="state"),div(class="contact-name","Aaron Smith"),div(class="contact-title","Remote Research and Development Analyst"),div(class="contact-info","Hometown: Kansas City, MO",br(),"Email: ",tags$a(href="mailto:asmith8@bu.com","asmith8@bu.com"),br(),"Class: Junior (Boston University)"),div(class="contact-title","X: @AaronSmit1788"))
)),
# ANALYTICS RESOURCES
tabPanel("Analytics Resources", div(class="app-container",
h2("Training Resources",class="section-header"),
div(class="resource-item",div(class="resource-title","TrackMan Data Dictionary"),div(class="resource-description","Complete guide to understanding TrackMan metrics and terminology"),tags$a("Download PDF",href="TrackMan - V3 Data Glossary (20240205) (3) (3) (1).pdf",target="_blank")),
div(class="resource-item",div(class="resource-title","TrackMan Tagging App Manual"),div(class="resource-description","Step-by-step process for tagging games and using TrackMan systems"),tags$a("Download PDF",href="Tagging App Manual v2.4 (3).pdf",target="_blank")),
div(class="resource-item",div(class="resource-title","Analytics Rulebook"),div(class="resource-description","Coastal Carolina Baseball Analytics Department guidelines and rules"),tags$a("Download PDF",href="Analytics Rulebook.pdf",target="_blank")),
h2("Video Tutorials & Channels",class="section-header",style="margin-top:30px;"),
div(class="resource-item",div(class="resource-title","Lance Brozdowski YouTube Channel"),tags$a("Visit Channel",href="https://www.youtube.com/@LanceBroz",target="_blank")),
div(class="resource-item",div(class="resource-title","Tread Athletics YouTube Channel"),tags$a("Visit Channel",href="https://www.youtube.com/@treadathletics",target="_blank")),
div(class="resource-item",div(class="resource-title","Simple Sabermetrics YouTube Channel"),tags$a("Visit Channel",href="https://www.youtube.com/@SimpleSabermetrics",target="_blank")),
div(class="resource-item",div(class="resource-title","Robert Frey YouTube Channel"),tags$a("Visit Channel",href="https://www.youtube.com/@robertfrey40",target="_blank")),
h2("Articles & Apps",class="section-header",style="margin-top:30px;"),
div(class="resource-item",div(class="resource-title","The Art of Hiding a Bad Fastball"),div(class="resource-description","Article by Isaac Groffman"),tags$a("Read Article",href="https://isaacgrofman.substack.com/p/the-art-of-hiding-a-bad-fastball",target="_blank")),
h2("External Links",class="section-header",style="margin-top:30px;"),
div(class="resource-item",div(class="resource-title","Baseball Savant"),tags$a("Visit Site",href="https://baseballsavant.mlb.com/",target="_blank")),
div(class="resource-item",div(class="resource-title","FanGraphs"),tags$a("Visit Site",href="https://www.fangraphs.com/",target="_blank")),
h2("Logos",class="section-header",style="margin-top:30px;"),
fluidRow(column(3,tags$img(src="https://i.imgur.com/5GpVZS3.png",height="120px",loading="lazy")),column(3,tags$img(src="https://i.imgur.com/TLEoAiS.png",height="120px",loading="lazy")),column(3,tags$img(src="https://i.imgur.com/3g2JJI5.png",height="120px",loading="lazy")),column(3,tags$img(src="https://i.imgur.com/tGPeTPu.png",height="120px",loading="lazy")))
)),
# SCORES TAB
tabPanel("Scores", div(style="max-width:1100px;margin:0 auto;",
div(class="scores-controls",
tags$button(id="scores_prev",class="scores-nav-btn action-button",HTML("&#8592;"),title="Previous Day"),
div(class="scores-date-display",textOutput("scores_date_label",inline=TRUE)),
tags$button(id="scores_next",class="scores-nav-btn action-button",HTML("&#8594;"),title="Next Day"),
dateInput("scores_date",label=NULL,value=Sys.Date(),width="150px"),
actionButton("scores_today","Today",style="background:peru;color:#fff;border:none;border-radius:20px;padding:6px 18px;font-weight:700;font-size:13px;cursor:pointer;"),
actionButton("scores_refresh","\U0001F504 Refresh",style="background:darkcyan;color:#fff;border:none;border-radius:20px;padding:6px 18px;font-weight:700;font-size:13px;cursor:pointer;")
),
div(class="scores-filter-row",
actionButton("filter_all","All Games",class="scores-filter-btn active"),
actionButton("filter_live","Live",class="scores-filter-btn"),
actionButton("filter_final","Final",class="scores-filter-btn"),
actionButton("filter_upcoming","Upcoming",class="scores-filter-btn"),
actionButton("filter_top25","Top 25",class="scores-filter-btn"),
actionButton("filter_late","Late",class="scores-filter-btn"),
actionButton("filter_close","Close",class="scores-filter-btn")),
div(style="max-width:300px;margin:0 auto 12px;",
selectInput("scores_conference", label=NULL, choices=c("All Conferences"), selected="All Conferences", width="100%")
),
div(style="max-width:400px;margin:0 auto 20px;",textInput("scores_search",label=NULL,placeholder="Search teams...",width="100%")),
uiOutput("scores_board"),
uiOutput("scores_count_label")
)),
tabPanel("Rankings", div(style="max-width:900px;margin:0 auto;",
div(class="poll-selector", uiOutput("poll_buttons")),
actionButton("rankings_refresh", "Refresh Rankings", style="display:block;margin:0 auto 20px;background:darkcyan;color:#fff;border:none;border-radius:20px;padding:8px 20px;font-weight:700;font-size:13px;cursor:pointer;"),
uiOutput("rankings_table")
)),
# RECRUITING MAPS
tabPanel("2026 Recruiting Maps", fluidRow(style="max-width:1400px;margin:0 auto;",
column(6,div(style="padding:8px;text-align:center;",tags$img(src='https://i.imgur.com/rpeLB3B.png',style='max-width:100%;height:auto;',loading='lazy'))),
column(6,div(style="padding:8px;text-align:center;",tags$img(src='https://i.imgur.com/Le2Sq52.png',style='max-width:100%;height:auto;',loading='lazy')))
))
)
)
),
# ── BIO PAGE ────────────────────────────────────────────────────────────────
conditionalPanel(condition="input.current_page=='bio'",
div(class="bio-container",actionButton("back_to_main","\u2190 Back to Main",class="back-btn"),uiOutput("bio_content"))
)
)
# ═══════════════════════════════════════════════════════════════════════════════
# SERVER
# ═══════════════════════════════════════════════════════════════════════════════
server <- function(input, output, session) {
values <- reactiveValues(current_page="main", selected_person=NULL, scores_filter="all",
rankings_data=NULL, selected_poll=NULL)
observeEvent(values$current_page, {
shinyjs::runjs(sprintf("Shiny.setInputValue('current_page','%s',{priority:'event'});", values$current_page))
})
# ── FETCH SCORES (shared reactive) ─────────────────────────────────────────
scores_data <- reactive({
input$scores_refresh
d <- input$scores_date
if (is.null(d)) d <- Sys.Date()
tryCatch({
games <- fetch_espn_scoreboard(game_date = as.Date(d))
list(games = games, error = attr(games, "fetch_error"))
}, error = function(e) list(games = tibble::tibble(), error = conditionMessage(e)))
})
# ── TICKER (today's scores, always) ────────────────────────────────────────
ticker_data <- reactive({
invalidateLater(120000, session) # auto-refresh every 2 min
tryCatch({
games <- fetch_espn_scoreboard(game_date = Sys.Date())
if (nrow(games) == 0) return(tibble::tibble())
games
}, error = function(e) tibble::tibble())
})
output$scores_ticker <- renderUI({
games <- ticker_data()
if (is.null(games) || nrow(games) == 0) {
# Show empty ticker bar
return(div(class = "scores-ticker-wrap",
div(class = "scores-ticker-track", style = "animation:none;justify-content:center;",
div(class = "ticker-empty", "\u26BE No games today \u2014 check back later")
)
))
}
# Sort: Coastal first, then Top 25, then rest
games <- games %>% mutate(
is_coastal = vapply(seq_len(n()), function(i) is_coastal_game(games[i,]), logical(1)),
ticker_order = case_when(is_coastal ~ 0L, top25_game ~ 1L, TRUE ~ 2L)
) %>% arrange(ticker_order, sort(status_category == "live", decreasing = TRUE))
# Build chip HTML
chips <- vapply(seq_len(nrow(games)), function(i) {
paste0(render_ticker_chip(games[i,]), '<div class="ticker-divider"></div>')
}, character(1))
all_chips <- paste(chips, collapse = "\n")
# View All button
view_all_btn <- '<button class="ticker-view-all" onclick="Shiny.setInputValue(\'goto_scores_tab\', Math.random());">View All Scores \u25B6</button>'
tagList(
div(class = "scores-ticker-wrap",
div(class = "scores-ticker-track",
HTML(all_chips),
HTML(view_all_btn)
)
)
)
})
# ── RANKINGS ───────────────────────────────────────────────────────────────
observe({ if (is.null(values$rankings_data)) values$rankings_data <- fetch_espn_rankings() })
observeEvent(input$rankings_refresh, { values$rankings_data <- fetch_espn_rankings() })
output$poll_buttons <- renderUI({
data <- values$rankings_data
if (is.null(data) || length(data$polls) == 0) return(NULL)
poll_names <- names(data$polls)
if (is.null(values$selected_poll) || !(values$selected_poll %in% poll_names)) values$selected_poll <- poll_names[1]
lapply(poll_names, function(pn) {
cls <- if (identical(pn, values$selected_poll)) "poll-btn active" else "poll-btn"
actionButton(paste0("poll_", gsub("[^a-zA-Z0-9]","_",pn)), label=pn, class=cls)
})
})
observe({
data <- values$rankings_data
if (is.null(data) || length(data$polls) == 0) return()
for (pn in names(data$polls)) {
local({ local_pn <- pn
observeEvent(input[[paste0("poll_",gsub("[^a-zA-Z0-9]","_",local_pn))]], { values$selected_poll <- local_pn }, ignoreInit=TRUE)
})
}
})
output$rankings_table <- renderUI({
data <- values$rankings_data
if (is.null(data)) return(div(style="text-align:center;padding:40px;color:#999;","Loading..."))
if (!is.null(data$error)) return(div(style="text-align:center;padding:40px;color:#e74c3c;",data$error))
if (length(data$polls)==0) return(div(style="text-align:center;padding:40px;color:#999;","No rankings available."))
poll_name <- values$selected_poll
if (is.null(poll_name)||!(poll_name %in% names(data$polls))) poll_name <- names(data$polls)[1]
df <- data$polls[[poll_name]]
if (is.null(df)||nrow(df)==0) return(div(style="text-align:center;padding:40px;color:#999;","No data."))
rows_html <- paste(vapply(seq_len(nrow(df)), function(i) {
r <- df[i,]
logo_html <- if(!is.na(r$logo)) sprintf('<img src="%s" style="width:28px;height:28px;object-fit:contain;vertical-align:middle;margin-right:8px;" onerror="this.style.display=\'none\'">',r$logo) else ""
trend_html <- ""
if(!is.na(r$previous_rank)&&!is.na(r$current_rank)){
diff<-r$previous_rank-r$current_rank
if(diff>0) trend_html<-sprintf('<span class="rank-up">\u25B2 %d</span>',diff)
else if(diff<0) trend_html<-sprintf('<span class="rank-down">\u25BC %d</span>',abs(diff))
else trend_html<-'<span class="rank-same">\u2014</span>'
}
sprintf('<tr><td class="rank-cell">%d</td><td>%s%s</td><td>%s</td><td style="text-align:center;">%s</td><td style="text-align:center;">%s</td></tr>',
r$current_rank,logo_html,htmltools::htmlEscape(r$team_name),r$record%||%"",trend_html,if(!is.na(r$points)) as.character(r$points) else "")
}, character(1)), collapse="")
HTML(sprintf('<table class="rankings-table"><tr><th style="text-align:center;width:50px;">Rank</th><th>Team</th><th>Record</th><th style="text-align:center;">Trend</th><th style="text-align:center;">Pts</th></tr>%s</table>',rows_html))
})
# Handle "View All Scores" click β†’ switch to Scores tab
observeEvent(input$goto_scores_tab, {
updateTabsetPanel(session, "main_tabs", selected = "Scores")
})
# ── SCORES TAB ─────────────────────────────────────────────────────────────
output$scores_date_label <- renderText({
d <- input$scores_date; if (is.null(d)) return(""); format(as.Date(d), "%A, %B %d, %Y")
})
observeEvent(input$scores_prev, { updateDateInput(session, "scores_date", value = as.Date(input$scores_date) - 1) })
observeEvent(input$scores_next, { updateDateInput(session, "scores_date", value = as.Date(input$scores_date) + 1) })
observeEvent(input$scores_today, { updateDateInput(session, "scores_date", value = Sys.Date()) })
observeEvent(input$filter_all, { values$scores_filter <- "all" })
observeEvent(input$filter_live, { values$scores_filter <- "live" })
observeEvent(input$filter_final, { values$scores_filter <- "final" })
observeEvent(input$filter_upcoming, { values$scores_filter <- "upcoming" })
observeEvent(input$filter_top25, { values$scores_filter <- "top25" })
observeEvent(input$filter_late, { values$scores_filter <- "late" })
observeEvent(input$filter_close, { values$scores_filter <- "close" })
observe({
result <- scores_data(); games <- result$games
if (is.null(games) || nrow(games) == 0) return()
confs <- sort(unique(games$conference[!is.na(games$conference) & nzchar(games$conference)]))
# Sun Belt always first
sun_belt <- confs[grepl("Sun Belt", confs, fixed = TRUE)]
others <- confs[!grepl("Sun Belt", confs, fixed = TRUE)]
ordered <- c("All Conferences", sun_belt, others)
current <- isolate(input$scores_conference)
if (is.null(current) || !(current %in% ordered)) current <- "All Conferences"
updateSelectInput(session, "scores_conference", choices = ordered, selected = current)
})
observe({
f <- values$scores_filter
btns <- c("filter_all","filter_live","filter_final","filter_upcoming","filter_top25","filter_late","filter_close")
maps <- c("all","live","final","upcoming","top25","late","close")
for (i in seq_along(btns)) {
if (maps[i] == f) shinyjs::runjs(sprintf("document.getElementById('%s').classList.add('active');", btns[i]))
else shinyjs::runjs(sprintf("document.getElementById('%s').classList.remove('active');", btns[i]))
}
})
output$scores_board <- renderUI({
result <- scores_data(); games <- result$games; err <- result$error
if (!is.null(err) && nzchar(err)) return(div(class="scores-empty",div(class="scores-empty-icon","\u26A0\uFE0F"),div(class="scores-empty-text","Could not load scores"),div(class="scores-empty-sub",err)))
if (is.null(games) || nrow(games) == 0) return(div(class="scores-empty",div(class="scores-empty-icon","\u26BE"),div(class="scores-empty-text","No games scheduled"),div(class="scores-empty-sub","Try a different date or check back later")))
f <- values$scores_filter
if (f == "live") games <- games %>% filter(status_category == "live")
else if (f == "final") games <- games %>% filter(status_category == "final")
else if (f == "late") games <- games %>% filter(live_late == TRUE)
else if (f == "close") games <- games %>% filter(live_close == TRUE)
else if (f == "upcoming") games <- games %>% filter(status_category == "upcoming")
else if (f == "top25") games <- games %>% filter(top25_game == TRUE)
conf_filter <- input$scores_conference %||% "All Conferences"
if (conf_filter != "All Conferences") games <- games %>% filter(!is.na(conference) & conference == conf_filter)
search_term <- tolower(trimws(input$scores_search %||% ""))
if (nzchar(search_term)) games <- games %>% filter(grepl(search_term,tolower(away),fixed=TRUE)|grepl(search_term,tolower(home),fixed=TRUE)|grepl(search_term,tolower(away_school),fixed=TRUE)|grepl(search_term,tolower(home_school),fixed=TRUE))
if (nrow(games) == 0) return(div(class="scores-empty",div(class="scores-empty-icon","\U0001F50D"),div(class="scores-empty-text","No games match your filters")))
parts <- list()
section_order <- c("live","upcoming","final","canceled")
section_labels <- c(live="Live Games",upcoming="Upcoming",final="Final",canceled="Canceled")
for (cat in section_order) {
cat_games <- games %>% filter(status_category == cat)
if (nrow(cat_games) == 0) next
parts[[length(parts)+1]] <- sprintf('<div class="scores-section-label">%s (%d)</div>', section_labels[[cat]], nrow(cat_games))
for (i in seq_len(nrow(cat_games))) parts[[length(parts)+1]] <- render_game_card(cat_games[i,])
}
div(class="scores-grid", HTML(paste(parts, collapse="\n")))
})
# ── RANKINGS ───────────────────────────────────────────────────────────────
observe({ if (is.null(values$rankings_data)) values$rankings_data <- fetch_espn_rankings() })
observeEvent(input$rankings_refresh, { values$rankings_data <- fetch_espn_rankings() })
output$poll_buttons <- renderUI({
data <- values$rankings_data
if (is.null(data) || length(data$polls) == 0) return(NULL)
poll_names <- names(data$polls)
if (is.null(values$selected_poll) || !(values$selected_poll %in% poll_names)) values$selected_poll <- poll_names[1]
lapply(poll_names, function(pn) {
cls <- if (identical(pn, values$selected_poll)) "poll-btn active" else "poll-btn"
actionButton(paste0("poll_", gsub("[^a-zA-Z0-9]", "_", pn)), label = pn, class = cls)
})
})
observe({
data <- values$rankings_data
if (is.null(data) || length(data$polls) == 0) return()
for (pn in names(data$polls)) {
local({ local_pn <- pn
btn_id <- paste0("poll_", gsub("[^a-zA-Z0-9]", "_", local_pn))
observeEvent(input[[btn_id]], { values$selected_poll <- local_pn }, ignoreInit = TRUE)
})
}
})
output$rankings_table <- renderUI({
data <- values$rankings_data
if (is.null(data)) return(div(style="text-align:center;padding:40px;color:#999;","Loading rankings..."))
if (!is.null(data$error)) return(div(style="text-align:center;padding:40px;color:#e74c3c;", data$error))
if (length(data$polls) == 0) return(div(style="text-align:center;padding:40px;color:#999;","No rankings data available yet."))
poll_name <- values$selected_poll
if (is.null(poll_name) || !(poll_name %in% names(data$polls))) poll_name <- names(data$polls)[1]
df <- data$polls[[poll_name]]
if (is.null(df) || nrow(df) == 0) return(div(style="text-align:center;padding:40px;color:#999;","No data for this poll."))
rows_html <- paste(vapply(seq_len(nrow(df)), function(i) {
r <- df[i,]
logo_html <- if (!is.na(r$logo) && nzchar(r$logo)) {
sprintf('<img src="%s" onerror="this.style.display=\'none\'">', r$logo)
} else ""
trend_html <- ""
if (!is.na(r$previous_rank) && !is.na(r$current_rank)) {
diff <- r$previous_rank - r$current_rank
if (diff > 0) trend_html <- sprintf('<span class="rank-up">\u25B2 %d</span>', diff)
else if (diff < 0) trend_html <- sprintf('<span class="rank-down">\u25BC %d</span>', abs(diff))
else trend_html <- '<span class="rank-same">\u2014</span>'
}
sprintf('<div class="rankings-row"><div class="rank-num">%d</div><div class="rank-team">%s%s</div><div class="rank-record">%s</div><div class="rank-trend">%s</div><div class="rank-pts">%s</div></div>',
r$current_rank, logo_html, htmltools::htmlEscape(r$team_name),
if (!is.na(r$record)) r$record else "",
trend_html,
if (!is.na(r$points)) format(r$points, big.mark=",") else "")
}, character(1)), collapse = "")
HTML(paste0(
'<div class="rankings-container">',
'<div class="rankings-header"><div style="text-align:center;">Rk</div><div>Team</div><div style="text-align:center;">Record</div><div style="text-align:center;">Trend</div><div style="text-align:center;">Pts</div></div>',
rows_html,
'</div>'
))
})
output$scores_count_label <- renderUI({
result <- scores_data(); games <- result$games
if (is.null(games) || nrow(games) == 0) return(NULL)
n_live <- sum(games$status_category=="live",na.rm=TRUE); n_final <- sum(games$status_category=="final",na.rm=TRUE); n_upcoming <- sum(games$status_category=="upcoming",na.rm=TRUE)
p <- c(); if(n_live>0) p <- c(p,sprintf("%d live",n_live)); if(n_final>0) p <- c(p,sprintf("%d final",n_final)); if(n_upcoming>0) p <- c(p,sprintf("%d upcoming",n_upcoming))
div(class="scores-count",sprintf("%d total games \u2022 %s",nrow(games),paste(p,collapse=" \u2022 ")))
})
# ── BIO ────────────────────────────────────────────────────────────────────
observeEvent(input$bio_kevin_schnall,{values$current_page<-"bio";values$selected_person<-"kevin_schnall"})
observeEvent(input$bio_chad_oxendine,{values$current_page<-"bio";values$selected_person<-"chad_oxendine"})
observeEvent(input$bio_matt_williams,{values$current_page<-"bio";values$selected_person<-"matt_williams"})
observeEvent(input$bio_matt_schilling,{values$current_page<-"bio";values$selected_person<-"matt_schilling"})
observeEvent(input$bio_tyler_shewmaker,{values$current_page<-"bio";values$selected_person<-"tyler_shewmaker"})
observeEvent(input$bio_dylan_eskew,{values$current_page<-"bio";values$selected_person<-"dylan_eskew"})
observeEvent(input$bio_mickey_beach,{values$current_page<-"bio";values$selected_person<-"mickey_beach"})
observeEvent(input$bio_matt_pepin,{values$current_page<-"bio";values$selected_person<-"matt_pepin"})
observeEvent(input$bio_connor_ownings,{values$current_page<-"bio";values$selected_person<-"connor_ownings"})
observeEvent(input$bio_mike_thomson,{values$current_page<-"bio";values$selected_person<-"mike_thomson"})
observeEvent(input$bio_tanner_costine,{values$current_page<-"bio";values$selected_person<-"tanner_costine"})
observeEvent(input$bio_jordan_bowman,{values$current_page<-"bio";values$selected_person<-"jordan_bowman"})
observeEvent(input$bio_megan_gregory,{values$current_page<-"bio";values$selected_person<-"megan_gregory"})
observeEvent(input$bio_mike_cruise,{values$current_page<-"bio";values$selected_person<-"mike_cruise"})
observeEvent(input$bio_josh_finklea,{values$current_page<-"bio";values$selected_person<-"josh_finklea"})
observeEvent(input$back_to_main, { values$current_page <- "main"; values$selected_person <- NULL })
output$bio_content <- renderUI({
req(values$selected_person)
person_data <- bio_data[[values$selected_person]]
if (is.null(person_data)) return(div("Bio not found"))
tagList(
div(
class = "bio-header",
if (!is.null(person_data$headshot) && nzchar(person_data$headshot)) {
tags$img(
src = person_data$headshot,
class = "bio-headshot",
alt = paste(person_data$name, "headshot")
)
},
if (!is.null(person_data$state) && nzchar(person_data$state)) {
tags$img(
src = person_data$state,
style = "width:100px;height:auto;margin:10px auto;display:block;",
alt = "State"
)
},
div(class = "bio-name", person_data$name),
div(class = "bio-title", person_data$title),
div(
class = "bio-info",
if (!is.null(person_data$hometown) && nzchar(person_data$hometown)) {
tags$div(paste("Hometown:", person_data$hometown))
},
if (!is.null(person_data$email) && nzchar(person_data$email)) {
tags$div(
"Email: ",
tags$a(href = paste0("mailto:", person_data$email), person_data$email)
)
},
if (!is.null(person_data$seasons) && nzchar(person_data$seasons)) {
tags$div(person_data$seasons)
}
)
),
div(
class = "bio-content",
div(class = "bio-section-title", "Biography"),
div(class = "bio-text", person_data$bio)
),
if (!is.null(person_data$achievements) && length(person_data$achievements) > 0) {
div(
class = "bio-content",
div(class = "bio-section-title", "Key Achievements"),
tags$ul(
class = "achievements-list",
lapply(person_data$achievements, function(a) tags$li(a))
)
)
},
if (!is.null(person_data$additional_images) && length(person_data$additional_images) > 0) {
div(
class = "bio-content",
div(class = "bio-section-title", "Gallery"),
div(
class = "additional-images",
lapply(person_data$additional_images, function(u) {
ok <- is.character(u) && length(u) == 1 && !is.na(u) && nzchar(u)
if (ok) tags$img(src = u, alt = "Gallery image", loading = "lazy") else NULL
})
)
)
}
)
})
}
shinyApp(ui, server)