Generating an NBA Team’s Entire Schedule as a Google Calendar with R

R Packages hoopR SportsDataVerse calendar gsg

Specifically, the San Antonio Spurs. (But feel free to tweak it for your team!)

Ryan McShane https://ryanmcshane.com
2024-10-20

Motivation

The NBA season is upon us, and I am excited to see how the Spurs look after Wemby’s year so far (post all-star break RoY campaign and near DPoY win) and with the additions of CP3 and Barnes, the return of Bassey, and Sandro Mamukelashvili’s April surge when he showed chemistry with Wemby. I watch as many of the Spurs game as I can. One of the benefits to living outside of San Antonio is that NBA Team Pass will get you most of the games, live (and this will be my tenth season with the streaming service).

To that end, I decided that I wanted to add the Spurs’ schedule to my personal Google calendar. However, the Spurs use eCal to distribute their calendar and eCal is essentially marketing software. I don’t need advertisements in my Google calendar nor do I want to donate my personal information to the Spurs’ marketing team!

So, I set out to find out how to systematically generate a batch of Google Calendar events given season data. I knew I needed to either create a .csv or an .ical file to read into Google Calendar. I chose the latter, although the .csv route may have been easier. For you, dear reader, it will still be easier to borrow my functions.

Getting the Schedule Data

One of the packages in Saiem Gilani’s sportsdataverse is hoopR, which accesses a variety of NBA data sources across the web. The hoopR::load_nba_schedule function will give us the most recent NBA schedule available by default (schedules are generally published in August, so this will now give us the 2024-25 season). At the moment, 1200 games are scheduled (80 games per 30 teams) as the NBA Cup will determine how the last two regular season games per team are scheduled.

I wrote a wrapper function, get_nba_schedule which gets the data I want and discards the rest:

get_nba_schedule = function() {
  hoopR::load_nba_schedule() |> 
    dplyr::select(
      game_date_time, broadcast_name,
      home_location, home_name, home_abbreviation, home_display_name, 
      away_location, away_name, away_abbreviation, away_display_name
    ) |> 
    dplyr::arrange(game_date_time) |>
    dplyr::mutate(
      game_date_time = 
        lubridate::as_datetime(x = game_date_time, tz = "EST") 
          # + lubridate::hours(1)
        # I'm not 100% sure what's happening here, but this seemed to work
    )
}
nba_schedule = get_nba_schedule()
print(nba_schedule, n = 3)
# A tibble: 1,200 × 10
  game_date_time      broadcast_name  home_location home_name
  <dttm>              <chr>           <chr>         <chr>    
1 2024-10-22 18:30:00 TNT             Boston        Celtics  
2 2024-10-22 21:00:00 TNT             Los Angeles   Lakers   
3 2024-10-23 18:00:00 NBA League Pass Detroit       Pistons  
# ℹ 1,197 more rows
# ℹ 6 more variables: home_abbreviation <chr>,
#   home_display_name <chr>, away_location <chr>, away_name <chr>,
#   away_abbreviation <chr>, away_display_name <chr>

Then I wrote a function, create_event_data, which by default refines that data for my specific team (note: the function is designed for a single team; were you to use this for multiple teams, you would get redundant calendar events). For your own team, find their short code (usually 2-3 capital letters). Additionally, you may want to write your own helper function to relabel the broadcast stations. I have NBA Team Pass out of market, so games are either nationally broadcast or on NBA Team Pass. I’ll be going to the Spurs-Bulls game, so I’m not worried about that obvious edge case 😂.

# change this for your broadcast needs; a lot of the values were blank in the data
ryan_broadcast_situation = function(x) {
  output = dplyr::if_else(
      condition = x %in% c("TNT", "ESPN", "NBA TV", "ABC"), 
      true = x, 
      false = "NBA Team Pass"
    )
return(output)
}

create_event_data = function(data = nba_schedule, team = "SA") {
  team_data = {{data}} |> 
    dplyr::filter(home_abbreviation == "SA") |> 
    dplyr::slice(1)
  {{data}} |> 
    dplyr::filter(home_abbreviation == {{team}} | away_abbreviation == {{team}}) |>
    dplyr::mutate(
      event_title = paste0(
        team_data[["home_name"]], 
        dplyr::if_else(
          condition = home_abbreviation == {{team}}, 
          true = paste0(" vs. ", away_name), 
          false = paste0(" at ", home_name)
        )
      ), 
      end_time = (game_date_time + lubridate::minutes(150)), 
      broadcast_name = ryan_broadcast_situation(broadcast_name)
    ) |> 
    dplyr::select(event_title, game_date_time, end_time, broadcast_name)
}
spurs_schedule = create_event_data()
print(spurs_schedule, n = 3)
# A tibble: 80 × 4
  event_title   game_date_time      end_time            broadcast_name
  <chr>         <dttm>              <dttm>              <chr>         
1 Spurs at Mav… 2024-10-24 18:30:00 2024-10-24 21:00:00 TNT           
2 Spurs vs. Ro… 2024-10-26 19:30:00 2024-10-26 22:00:00 NBA Team Pass 
3 Spurs vs. Ro… 2024-10-28 19:00:00 2024-10-28 21:30:00 NBA Team Pass 
# ℹ 77 more rows

Converting the Schedule to iCalendar Data

The iCalendar open source format looks for a very specific structure in its files. Google notes there are very few required entries (see also this StackOverflow post). At the moment, all I really care about is including a name for the event, a correct start and end time, and the broadcaster through which I will be viewing the game (I’m out of luck for games on NBA TV or TNT).

I found Robin Lovelace’s calendar R package, which is on CRAN and was conveniently already installed on my computer (I don’t know it ended up on my computer, honestly). Using the calendar::ic_event function, I was able to create a single event object (I was not able to create multiple events from a single data frame with a single function from the package, although it may be possible).

# to install the package if needed
# pak::pak("calendar")

spurs_event = function(data = spurs_schedule, row = 1, 
                       extra_properties = vector(mode = "character")) {
  input = {{data}}[row, ]
  spurs_game = calendar::ic_event(
    start_time = input[["game_date_time"]], 
    end_time = input[["end_time"]], 
    summary = input[["event_title"]],
    more_properties = TRUE, 
    event_properties = c(
        LOCATION = input[["broadcast_name"]],
        extra_properties
      )
    ) |> 
    calendar::ic_character()
  # spurs_game[spurs_game == "PLACEHOLDER:xyz"] = extra_semi_properties
return(spurs_game)
}

A single event is simply a vector of values describing the event, most in a KEYWORD:value format1.

# use this line of code to create a named vector of parameters to pass
calendar::properties |> sort()
 [1] "ACTION"       "ATTACH"       "ATTENDEE"     "AUDIO"       
 [5] "CALSCALE"     "CATEGORIES"   "CLASS"        "COMMENT"     
 [9] "COMPLETED"    "CONFIDENTIAL" "CONTACT"      "CREATED"     
[13] "DESCRIPTION"  "DISPLAY"      "DTEND"        "DTSTAMP"     
[17] "DTSTART"      "DUE"          "DURATION"     "EMAIL"       
[21] "EXDATE"       "FLOAT"        "FREEBUSY"     "GEO"         
[25] "INTEGER"      "LAST"         "LOCATION"     "METHOD"      
[29] "ORGANIZER"    "PERCENT"      "PRIORITY"     "PRIVATE"     
[33] "PRODID"       "PUBLIC"       "RDATE"        "RECURRENCE"  
[37] "RELATED"      "REPEAT"       "REQUEST"      "RESOURCES"   
[41] "RRULE"        "SEQUENCE"     "STATUS"       "SUMMARY"     
[45] "TRANSP"       "TRIGGER"      "TZID"         "TZNAME"      
[49] "TZOFFSETFROM" "TZOFFSETTO"   "TZURL"        "UID"         
[53] "URL"          "VERSION"     

Here’s a couple of examples of some extra properties to pass:

mickey_guests = c(
  ORGANIZER = "CN=Mickey:mailto:mickey@disney.com", 
  ATTENDEE = "CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=Minnie;X-NUM-GUESTS=0:mailto:minnie@disney.com"
  )
# opening night game
spurs_event(row = 1, extra_properties = mickey_guests)
 [1] "BEGIN:VCALENDAR"                                                                                                        
 [2] "PRODID:-//ATFutures/ical //EN"                                                                                          
 [3] "VERSION:2.0"                                                                                                            
 [4] "CALSCALE:GREGORIAN"                                                                                                     
 [5] "METHOD:PUBLISH"                                                                                                         
 [6] "BEGIN:VEVENT"                                                                                                           
 [7] "UID:ical-17cb6f8f-d810-450d-9033-70b332ef697d"                                                                          
 [8] "DTSTART:20241024T183000"                                                                                                
 [9] "DTEND:20241024T210000"                                                                                                  
[10] "SUMMARY:Spurs at Mavericks"                                                                                             
[11] "LOCATION:TNT"                                                                                                           
[12] "ORGANIZER:CN=Mickey:mailto:mickey@disney.com"                                                                           
[13] "ATTENDEE:CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=Minnie;X-NUM-GUESTS=0:mailto:minnie@disney.com"
[14] "END:VEVENT"                                                                                                             
[15] "END:VCALENDAR"                                                                                                          
wemby_notes = c(
  DESCRIPTION = "Wemby might have a 5x5 tonight! (At least 5 points, 5 rebounds, 5 assists, 5 blocks, and 5 steals).", 
  URL = "https://www.nba.com/spurs/game/0022400130/box-score"
  )
# last night's game
spurs_event(row = 5, extra_properties = wemby_notes)
 [1] "BEGIN:VCALENDAR"                                                                                                
 [2] "PRODID:-//ATFutures/ical //EN"                                                                                  
 [3] "VERSION:2.0"                                                                                                    
 [4] "CALSCALE:GREGORIAN"                                                                                             
 [5] "METHOD:PUBLISH"                                                                                                 
 [6] "BEGIN:VEVENT"                                                                                                   
 [7] "UID:ical-bd90ad46-8366-4f58-96b0-f67733d3a06d"                                                                  
 [8] "DTSTART:20241031T200000"                                                                                        
 [9] "DTEND:20241031T223000"                                                                                          
[10] "SUMMARY:Spurs at Jazz"                                                                                          
[11] "LOCATION:NBA Team Pass"                                                                                         
[12] "DESCRIPTION:Wemby might have a 5x5 tonight! (At least 5 points, 5 rebounds, 5 assists, 5 blocks, and 5 steals)."
[13] "URL:https://www.nba.com/spurs/game/0022400130/box-score"                                                        
[14] "END:VEVENT"                                                                                                     
[15] "END:VCALENDAR"                                                                                                  

A workaround for some keys that use KEYSET;KEY1:val1;KEY2:val2 syntax

clean_semi_keys = function(x, semi_keys = c("ATTENDEE", "ORGANIZER")) {
  old_keys = paste0(semi_keys, ":")
  new_keys = paste0(semi_keys, ";")
  for (i in seq_len(length.out = length(semi_keys))) {
    x = gsub(pattern = old_keys[i], replacement = new_keys[i], x = x)
  }
return(x)
}
spurs_event(row = 1, extra_properties = mickey_guests) |> 
  clean_semi_keys()
 [1] "BEGIN:VCALENDAR"                                                                                                        
 [2] "PRODID:-//ATFutures/ical //EN"                                                                                          
 [3] "VERSION:2.0"                                                                                                            
 [4] "CALSCALE:GREGORIAN"                                                                                                     
 [5] "METHOD:PUBLISH"                                                                                                         
 [6] "BEGIN:VEVENT"                                                                                                           
 [7] "UID:ical-882d1c71-2767-45cc-83db-7b512aaca5f4"                                                                          
 [8] "DTSTART:20241024T183000"                                                                                                
 [9] "DTEND:20241024T210000"                                                                                                  
[10] "SUMMARY:Spurs at Mavericks"                                                                                             
[11] "LOCATION:TNT"                                                                                                           
[12] "ORGANIZER;CN=Mickey:mailto:mickey@disney.com"                                                                           
[13] "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=Minnie;X-NUM-GUESTS=0:mailto:minnie@disney.com"
[14] "END:VEVENT"                                                                                                             
[15] "END:VCALENDAR"                                                                                                          

Writing the iCalendar to a file that Google calendar can read

I ran into a snag with the calendar::ic_write function to write multiple events to a single .ics file and left a comment on an existing issue which seemed to result in the same error message:

list(spurs_event(row = 1), spurs_event(row = 4)) |>
  lapply(X = _, FUN = calendar::ic_vector) |> 
  calendar::ic_bind_list() |> 
  calendar::ic_write()
Error in format.POSIXct(x, "%Y%m%d"): wrong class

So, I wrote another function to work around this issue

create_cal_vec = function(data = spurs_schedule,
                          extra_properties = vector(mode = "character"), 
                          manual_strings = vector(mode = "character")){
# get every game of those available
  gather_cals = vector(length = nrow( {{data}} ), mode = "list")
  for (i in seq_len(length.out = nrow( {{data}} ))) {
    temp_event = spurs_event(data = {{data}} , row = i, extra_properties = extra_properties)
    begin_vevent = min(which(temp_event == "BEGIN:VEVENT"))
    end_vevent = max(which(temp_event == "END:VEVENT"))
    gather_cals[[i]] = temp_event[begin_vevent:end_vevent]
    stop_event = length(gather_cals[[i]])
    gather_cals[[i]] = append(
      x = gather_cals[[i]],
      values = manual_strings, 
      after = stop_event - 1
      )
  }
# get first lines for an icalendar object
  top = c("BEGIN:VCALENDAR", 
          "PRODID:-//mcshane/sportsCal //EN", 
          "VERSION:2.0", 
          "CALSCALE:GREGORIAN", 
          "METHOD:PUBLISH")
# stick all of the events together
  middle = do.call(what = c, args = gather_cals)
# last part
  bottom = "END:VCALENDAR"
return(c(top, middle, bottom))
}

Additionally, the icalendar standard requires that each line has no more than 75 characters per line. To continue a content line, you can use hanging indents. The number of characters allowed on a line with hanging indents is also 75, but after the white space you use to indent. To that end, I wrote folding_characters which takes a single element of an iCal vector and wraps it. I also wrote use_folding_characters which checks every element in an iCal vector and applies the function when necessary.

folding_characters = function(input_string, max_len = 75){
  len_string = nchar(input_string)  
  string_start = seq(from = 1, to = len_string, by = max_len)
  string_end = seq(from = max_len, to = len_string, by = max_len)
  if (length(string_end) < length(string_start)) {
    string_end = c(string_end, len_string)
  }
  tmp = substring(text = input_string, first = string_start, last = string_end)
return(paste(tmp, collapse = "\n "))
}

use_folding_characters = function(input_vector, max_len = 75) {
  for (i in seq_len(length.out = length(input_vector))) {
    if (nchar(input_vector[i]) > max_len) {
      input_vector[i] = input_vector[i] |> folding_characters(max_len = max_len)
    }
  }
return(input_vector)
}

A single .ics file with two events would look like this:

spurs_schedule[c(5, 6), ] |> 
  create_cal_vec(
    data = _, 
    extra_properties = wemby_notes[1]
  ) |>
  use_folding_characters() |>
  paste0("\n") |>
  cat(sep = "")
BEGIN:VCALENDAR
PRODID:-//mcshane/sportsCal //EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:ical-1afae9fa-27a7-4b60-9493-9162ed823239
DTSTART:20241031T200000
DTEND:20241031T223000
SUMMARY:Spurs at Jazz
LOCATION:NBA Team Pass
DESCRIPTION:Wemby might have a 5x5 tonight! (At least 5 points, 5 rebounds,
  5 assists, 5 blocks, and 5 steals).
END:VEVENT
BEGIN:VEVENT
UID:ical-eb46140e-d591-4774-95a3-6501c8e7779c
DTSTART:20241102T190000
DTEND:20241102T213000
SUMMARY:Spurs vs. Timberwolves
LOCATION:NBA Team Pass
DESCRIPTION:Wemby might have a 5x5 tonight! (At least 5 points, 5 rebounds,
  5 assists, 5 blocks, and 5 steals).
END:VEVENT
END:VCALENDAR

Here’s that function in action. It writes a file to my machine (I have an R Project set up, so it will save where I am working).

create_cal_vec() |> 
  clean_semi_keys() |>
  clean_semi_keys() |>
  readr::write_lines(file = "spurs_games.ics")

The Bad News about Google Calendar

Google Calendar is super picky about what you can upload and not (even if the iCalendar format allows the entry type).

Your Options

Here’s what it looks like in my calendar:

Enjoy making your own schedule!


  1. Note: ical is a little more complex than this – for keywords that accept multiple values, the format is KEYWORD;PARM1=val1;PARM2=val2 etc. This is notable for the ATTENDEE keyword. However, this is a moot point with Google Calendar, which does not read in the ATTENDEE keyword willingly, and even when it accepts the entries, doesn’t send everyone an invite. ↩︎