Specifically, the San Antonio Spurs. (But feel free to tweak it for your team!)
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.
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
iCalendar
DataThe 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"
iCalendar
to a file that Google calendar can readI 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")
Google Calendar is super picky about what you can upload and not (even if the iCalendar
format allows the entry type).
iCalendar
, when you upload an .ics
file, Google Calendar will not recognize the recurrence. Every event will be entirely independent of the rest. This is particularly annoying if you want to edit the entire series or delete the entire series (say, you messed up the time zone, or you want to add an attendee to the whole series)..ics
file into your personal Google calendar (gear icon \(\rightarrow\) Settings \(\rightarrow\) Import & Export \(\rightarrow\) Select file from your computer). Again, if there’s anything wrong with the series, you’ll have to edit or delete every single event manually.Here’s what it looks like in my calendar:
Enjoy making your own schedule!
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. ↩︎