---
title: 'How Much Do Back-to-Backs Cost?'
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{How Much Do Back-to-Backs Cost?}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = '#>',
  fig.align = 'center',
  out.width = '92%',
  fig.width = 7,
  fig.height = 4.6
)

make_table <- function(x, caption, digits = 3) {
  knitr::kable(x, caption = caption, digits = digits)
}
```

## Question

The second night of a back-to-back is one of hockey's favorite pregame excuses.
It sounds reasonable: tired legs, shorter meetings, travel, less goalie
certainty, no real practice day. But schedule complaints are easy to overstate.

This guided example asks a league-wide question:

> **In the salary-cap era, how much worse do teams perform when they play with
> zero days of rest?**

We will use `nhlscraper::games()` to build one row per team-game, calculate rest
from each team's previous game date, and compare win rate and goal differential.

## Build Team-Games

The source table has one row per game. Rest is a team-level property, so each
game becomes two records: one for the home team and one for the away team.

```{r data}
# Pull game and team catalogs.
games_tbl <- nhlscraper::games()
teams_tbl <- nhlscraper::teams()

# Keep completed salary-cap regular-season games.
games_tbl <- games_tbl[
  games_tbl[['seasonId']] >= 20052006 &
    games_tbl[['gameTypeId']] == 2 &
    !is.na(games_tbl[['homeScore']]) &
    !is.na(games_tbl[['visitingScore']]),
  c(
    'gameId',
    'seasonId',
    'gameDate',
    'homeTeamId',
    'visitingTeamId',
    'homeScore',
    'visitingScore'
  )
]

# Expand games into team-game rows.
home_games <- data.frame(
  gameId       = games_tbl[['gameId']],
  seasonId     = games_tbl[['seasonId']],
  gameDate     = as.Date(games_tbl[['gameDate']]),
  teamId       = games_tbl[['homeTeamId']],
  isHome       = TRUE,
  goalsFor     = games_tbl[['homeScore']],
  goalsAgainst = games_tbl[['visitingScore']]
)
away_games <- data.frame(
  gameId       = games_tbl[['gameId']],
  seasonId     = games_tbl[['seasonId']],
  gameDate     = as.Date(games_tbl[['gameDate']]),
  teamId       = games_tbl[['visitingTeamId']],
  isHome       = FALSE,
  goalsFor     = games_tbl[['visitingScore']],
  goalsAgainst = games_tbl[['homeScore']]
)
team_games <- rbind(home_games, away_games)

# Sort within team.
team_games <- team_games[order(
  team_games[['teamId']],
  team_games[['gameDate']],
  team_games[['gameId']]
), ]

# Compute previous game date within team.
team_games[['previousGameDate']] <- as.Date(NA)
for (team_id in unique(team_games[['teamId']])) {
  idx <- which(team_games[['teamId']] == team_id)
  team_games[['previousGameDate']][idx] <- c(
    as.Date(NA),
    utils::head(team_games[['gameDate']][idx], -1)
  )
}

# Create rest and result fields.
team_games[['restDays']] <-
  as.integer(team_games[['gameDate']] - team_games[['previousGameDate']]) - 1L
team_games <- team_games[!is.na(team_games[['restDays']]), ]
team_games[['restBucket']] <- ifelse(
  team_games[['restDays']] >= 3,
  '3+',
  as.character(team_games[['restDays']])
)
team_games[['restBucket']] <- factor(
  team_games[['restBucket']],
  levels = c('0', '1', '2', '3+')
)
team_games[['win']] <- team_games[['goalsFor']] > team_games[['goalsAgainst']]
team_games[['goalDiff']] <-
  team_games[['goalsFor']] - team_games[['goalsAgainst']]
nrow(team_games)
```

The definition is literal: `restDays = 0` means the team played yesterday. That
is the second night of a back-to-back.

## League-Wide Rest Curve

First we compare all team-games by rest bucket.

```{r rest-summary}
# Summarize results by rest bucket.
rest_summary <- aggregate(
  cbind(win, goalDiff) ~ restBucket,
  data = team_games,
  FUN = mean
)
rest_counts <- as.data.frame(table(team_games[['restBucket']]))
names(rest_counts) <- c('restBucket', 'games')
rest_summary <- merge(rest_summary, rest_counts, by = 'restBucket')
rest_summary <- rest_summary[
  match(levels(team_games[['restBucket']]), rest_summary[['restBucket']]),
  c('restBucket', 'games', 'win', 'goalDiff')
]
make_table(
  rest_summary,
  caption = 'Win rate and average goal differential by rest bucket.',
  digits = 3
)
```

The zero-rest penalty is visible in both columns. Teams on a back-to-back win
less often and get outscored on average. The biggest improvement comes from
moving from zero days of rest to one.

```{r rest-plot, fig.cap = 'Team performance by days of rest.'}
# Plot win rate and goal differential by rest bucket.
old_par <- graphics::par(no.readonly = TRUE)
graphics::par(mfrow = c(1, 2), mar = c(5, 4, 3, 1))
graphics::barplot(
  rest_summary[['win']],
  names.arg = rest_summary[['restBucket']],
  col = c('#d62828', '#f77f00', '#fcbf49', '#90be6d'),
  border = NA,
  ylim = c(0, 0.6),
  xlab = 'Days of Rest',
  ylab = 'Win Rate'
)
graphics::abline(h = mean(team_games[['win']]), lty = 2, col = '#495057')
graphics::barplot(
  rest_summary[['goalDiff']],
  names.arg = rest_summary[['restBucket']],
  col = c('#d62828', '#f77f00', '#fcbf49', '#90be6d'),
  border = NA,
  xlab = 'Days of Rest',
  ylab = 'Average Goal Differential'
)
graphics::abline(h = 0, lty = 2, col = '#495057')
graphics::par(old_par)
```

## Home Ice Does Not Erase Fatigue

Back-to-backs are not all equal. A tired team at home is still in a better spot
than a tired team on the road.

```{r venue-summary}
# Summarize rest effect by venue.
venue_summary <- aggregate(
  cbind(win, goalDiff) ~ restBucket + isHome,
  data = team_games,
  FUN = mean
)
venue_counts <- aggregate(
  gameId ~ restBucket + isHome,
  data = team_games,
  FUN = length
)
names(venue_counts)[names(venue_counts) == 'gameId'] <- 'games'
venue_summary <- merge(
  venue_summary,
  venue_counts,
  by = c('restBucket', 'isHome')
)
venue_summary[['venue']] <- ifelse(
  venue_summary[['isHome']],
  'Home',
  'Away'
)
venue_summary <- venue_summary[, c(
  'restBucket',
  'venue',
  'games',
  'win',
  'goalDiff'
)]
make_table(
  venue_summary,
  caption = 'Rest effect split by home and road games.',
  digits = 3
)
```

```{r venue-plot, fig.cap = 'Home and road win rate by rest bucket.'}
# Plot venue-specific rest curves.
home_rows <- venue_summary[venue_summary[['venue']] == 'Home', ]
away_rows <- venue_summary[venue_summary[['venue']] == 'Away', ]
graphics::plot(
  seq_len(nrow(home_rows)),
  home_rows[['win']],
  type = 'b',
  pch = 19,
  lwd = 2,
  col = '#1d3557',
  xaxt = 'n',
  ylim = c(0.34, 0.62),
  xlab = 'Days of Rest',
  ylab = 'Win Rate'
)
graphics::lines(
  seq_len(nrow(away_rows)),
  away_rows[['win']],
  type = 'b',
  pch = 19,
  lwd = 2,
  col = '#e63946'
)
graphics::axis(
  side = 1,
  at = seq_len(nrow(home_rows)),
  labels = home_rows[['restBucket']]
)
graphics::legend(
  'bottomright',
  legend = c('Home', 'Away'),
  col = c('#1d3557', '#e63946'),
  pch = 19,
  lwd = 2,
  bty = 'n'
)
```

The lines stay separated. Home ice helps, rest helps, and the worst combination
is exactly the one coaches complain about most: no rest on the road.

## Has the Schedule Become Kinder?

The league can reduce pain by reducing the share of team-games played on zero
rest. We can track that share by season.

```{r season-rest}
# Summarize zero-rest share by season.
season_rest <- aggregate(
  I(restDays == 0) ~ seasonId,
  data = team_games,
  FUN = mean
)
names(season_rest)[names(season_rest) == 'I(restDays == 0)'] <- 'zeroRestShare'
season_rest <- season_rest[order(season_rest[['seasonId']]), ]
season_text <- as.character(season_rest[['seasonId']])
season_rest[['season']] <- paste0(
  substr(season_text, 1, 4),
  '-',
  substr(season_text, 7, 8)
)
make_table(
  utils::tail(season_rest[, c('season', 'zeroRestShare')], 8),
  caption = 'Recent share of team-games played on zero rest.',
  digits = 3
)
```

```{r season-plot, fig.cap = 'Share of team-games played on zero rest by season.'}
# Plot season trend in zero-rest games.
season_x <- seq_len(nrow(season_rest))
label_idx <- seq(1L, nrow(season_rest), by = 2L)
old_par <- graphics::par(no.readonly = TRUE)
graphics::par(mar = c(7, 4, 3, 1))
graphics::plot(
  season_x,
  season_rest[['zeroRestShare']],
  type = 'h',
  lwd = 3,
  col = '#457b9d',
  xaxt = 'n',
  xlab = '',
  ylab = 'Zero-Rest Share'
)
graphics::points(
  season_x,
  season_rest[['zeroRestShare']],
  pch = 19,
  col = '#1d3557'
)
graphics::axis(
  side = 1,
  at = season_x[label_idx],
  labels = season_rest[['season']][label_idx],
  las = 2,
  cex.axis = 0.75
)
graphics::mtext('Season', side = 1, line = 5)
graphics::par(old_par)
```

This turns the article from "back-to-backs are hard" into a second question:
how often does the league ask teams to absorb that cost?

## Team Leaderboard

Once the team-game table exists, a league-wide question can become a team
identity question.

```{r team-table}
# Rank teams by zero-rest win rate.
zero_rest_tbl <- team_games[
  team_games[['restDays']] == 0,
  c('teamId', 'win', 'goalDiff')
]
zero_summary <- aggregate(
  cbind(win, goalDiff) ~ teamId,
  data = zero_rest_tbl,
  FUN = mean
)
zero_counts <- aggregate(
  win ~ teamId,
  data = zero_rest_tbl,
  FUN = length
)
names(zero_counts)[names(zero_counts) == 'win'] <- 'games'
zero_summary <- merge(zero_summary, zero_counts, by = 'teamId')
zero_summary <- zero_summary[zero_summary[['games']] >= 50, ]
zero_summary <- merge(
  zero_summary,
  teams_tbl[, c('teamId', 'teamTriCode')],
  by = 'teamId',
  all.x = TRUE
)
best_zero <- zero_summary[order(-zero_summary[['win']]), ]
best_zero <- utils::head(best_zero[, c(
  'teamTriCode',
  'games',
  'win',
  'goalDiff'
)], 8)
worst_zero <- zero_summary[order(zero_summary[['win']]), ]
worst_zero <- utils::head(worst_zero[, c(
  'teamTriCode',
  'games',
  'win',
  'goalDiff'
)], 8)
make_table(
  best_zero,
  caption = 'Best zero-rest win rates among teams with at least 50 games.',
  digits = 3
)
make_table(
  worst_zero,
  caption = 'Lowest zero-rest win rates among teams with at least 50 games.',
  digits = 3
)
```

This is where a broad endpoint becomes fan-readable. The same reshaped table can
support league averages, venue splits, season trends, and team debates.

## What We Learned

Back-to-backs are not just a broadcast excuse. In the salary-cap era, zero-rest
teams win less often and carry worse goal differential. The penalty is sharpest
on the road, and the league-wide cost is large enough to be visible with only
`games()` and a careful reshape.

The broader lesson is methodological: `nhlscraper` endpoints often start as
simple catalogs, but the interesting questions appear after you change the unit
of analysis. Here, one row per game became one row per team-game, and the
schedule suddenly had a measurable price.
