Custom Elements: Creating an NHL Schedule component

Preface

Web components have been around and stable for a while now, but I don't have much experience in building them or using them. To remedy that, I decided to build some straightforward custom elements for the NHL, starting with a schedule component.

What are Custom Elements?

To back up, Custom Elements are a part of the Web Components suite, which includes Custom Elements, the Shadow DOM, and HTML templates. Custom Elements are a way to define a specific structure of markup, events, and behavior into a single component. For now, I'm sticking with the "Light DOM" (popularized in part by this article by Adam Stoddard), where I don't bother with the Shadow DOM.

The benefits of creating a custom element that doesn't include any shadow DOM, templates, etc. is that markup and behavior can still be wrapped up into a single component, but it's easy for consumers to use them without having to pass in CSS variables or use other mechanisms to customize things.

Because a lot of what I'm planning to build will require fetching the required data (instead of being passed from the consumer and modified by my custom element), the progressive enhancement side of things won't apply as much here.

Let's get to it: <nhl-schedule>

I am going to start with a component to display the NHL schedule. I will start small, and expect this component to do the following things:

  • It should take in a date attribute, to show the schedule for a given day
  • When not passed, the schedule for the current day should be shown
  • It will display all the games for the date, and the scores

For reference, this is the current NHL schedule page.

partial screenshot of the NHL schedule page for November 4, 2023

The NHL has a general-purpose API that is completely open to use, and is wonderfully documented in this GitLab repo. To get the schedule, we will need to make a request to https://statsapi.web.nhl.com/api/v1/schedule?date={YYYY-MM-DD}&expand=schedule.linescore. As documented, the expand=schedule.linescore option will add the scoring information to the response so we can display it without having to make additional requests to get game state for several ongoing games.

Let's get into creating the actual custom element! To create a custom element, we create a class that extends HTMLElement, and add it to the CustomElementRegistry by calling customElements.define('element-name', ElementName). That way the window knows what <element-name> means, and can create the custom element correctly. Here's a completely minimal element that will just render the date we pass in as an attribute.

class NHLSchedule extends HTMLElement {
  static observedAttributes = ['date'];

  connectedCallback() {
    this.innerHTML = `<p>${this.getAttribute('date')}</p>`;
  }
}

customElements.define('nhl-schedule', NHLSchedule);

If we put <nhl-schedule date="2023-11-04"></nhl-schedule> into our document, we will see 2023-11-04 as the output.

See the Pen Untitled by Nathan Gingrich (@njgingrich) on CodePen.

So what does connectedCallback() do? It is a lifecycle method of a custom element, which is called every time the element is added to the document. This is the place to do any setup and call extra code, including fetching resources. The custom elements spec specifically notes that:

"work should be deferred to connectedCallback as much as possible - especially work involving fetching resources or rendering."

With that specified, it's clear we need to fetch data in connectedCallback and render it later.

Fetching Data

The next step is to actually get the schedule for the given date, and render some real output! I was somewhat stumped on how to actually do this from the get-go until I stumbled on this Gist by @richard-flosi on GitHub. In summary, we can set connectedCallback as async and fetch there. Within our fetch function, we update the loading attribute, and in attributeChangedCallback we render the HTML output. Here's the next version of our custom element, that now displays a basic table with game information.

class NHLSchedule extends HTMLElement { 
  constructor() {
    super();
    this.date = "2023-11-04";
    this._response = undefined;
  }
  
  get loading() {
    return JSON.parse(this.getAttribute("loading"));
  }
  
  set loading(val) {
   this.setAttribute("loading", JSON.stringify(val));
  }
  
  get response() {
    return this._response;
  }
  
  set response(res) {
    this._response = res;
  }
    
  async connectedCallback() {
    await this.fetchSchedule();
  }
 
  attributeChangedCallback(property, oldValue, newValue) {
    this.render();
  }
  
  static get observedAttributes() {
    return ['date', 'loading'];
  }
  
  // Implementation functions
  async fetchSchedule() {
    this.loading = true;
    const url = `https://statsapi.web.nhl.com/api/v1/schedule?date=${this.date}`;
    const res = await fetch(url);
    const data = await res.json();

    this.response = data.dates[0];
    this.loading = false;
  }
  
  render() {
    if (this.loading || !this.response) {
      this.innerHTML = 'Loading...';
    } else {
      const rows = this.response.games.map(game => {
        return `
          <tr>
            <td>${game.teams.away.team.name} @ ${game.teams.home.team.name}</td>
            <td>${game.status.detailedState}</td>
          </tr>
        `;
      });

      this.innerHTML = `
        <p>Schedule for ${this.date} (${this.response.totalGames} games)</p>
        <table>
          <thead>
            <tr>
              <th>Matchup</th>
              <th>Score</th>
            </tr>
          </thead>
          <tbody>
            ${rows.join('')}
          </tbody>
        </table>
      `;
    }
  }
}

customElements.define('nhl-schedule', NHLSchedule);

See the Pen NHL Schedule Custom Element - Async Fetch by Nathan Gingrich (@njgingrich) on CodePen.

It sure isn't pretty, but it's a start!

Making our component look nicer

Now that the bones are in place, we can make some improvements to display the actual score, use team abbreviations, and jazz it up a little bit. Unfortunately, the schedule response doesn't include the team abbreviation, so we need to source that elsewhere. That would be the teams resource from the NHL API, found at https://statsapi.web.nhl.com/api/v1/teams.

I don't expect the basics of this information to change, so I will put the pieces of this response I want into a static property on the class to use instead of making an additional request every time this component is rendered. Now we can render the score, with some logic to always show the winning score first.

render() {
    if (this.loading || !this.response) {
      this.innerHTML = 'Loading...';
    } else {
      const rows = this.response.games.map(game => {
        let score = game.status.detailedState;
        if (game.linescore) {
          const awayTeam = game.linescore.teams.away;
          const homeTeam = game.linescore.teams.home;
          
          if (awayTeam.goals > homeTeam.goals) {
            score = `<b>${this.getTeamAbbreviation(awayTeam.team.id)} ${awayTeam.goals}</b>, ${this.getTeamAbbreviation(homeTeam.team.id)} ${homeTeam.goals}`;
          } else {
            score = `<b>${this.getTeamAbbreviation(homeTeam.team.id)} ${homeTeam.goals}</b>, ${this.getTeamAbbreviation(awayTeam.team.id)} ${awayTeam.goals}`;
          }
        }

        return `
          <tr>
            <td>${game.teams.away.team.name} @ ${game.teams.home.team.name}</td>
            <td>${score}</td>
          </tr>
        `;
      });

      this.innerHTML = `
        <p>Schedule for ${this.date} (${this.response.totalGames} games)</p>
        <table>
          <thead>
            <tr>
              <th>Matchup</th>
              <th>Score</th>
            </tr>
          </thead>
          <tbody>
            ${rows.join('')}
          </tbody>
        </table>
      `;
    }
  }

Finally, we can add team logos. On the NHL website, those are sourced from https://assets.nhle.com/logos/nhl/svg/${abbr}_light.svg, so since we now have logic to get the team abbreviation we can use the same image resource.

See the Pen NHL Schedule webcomponent by Nathan Gingrich (@njgingrich) on CodePen.

Fixing some problems

First, instead of hardcoding the default date, it needs to default as the current date formatted as YYYY-MM-DD. That's a straightforward function (taking timezones into account):

static currentDate() {
    let today = new Date();
    const offset = today.getTimezoneOffset();
    today = new Date(today.getTime() - (offset * 60 * 1000));
    return today.toISOString().split('T')[0];
  }

Now that we display the score, it looks a bit weird for dates in the future (showing 0-0 scores). We can add a quick check to display the status if the date is in the future. Here's a subset of the render function where we get the markup for each row in the schedule:

const rows = this.response.games.map(game => {
    let scoreLine = game.status.detailedState;

    // If game is in future - status is 'Scheduled'
    if (game.status.codedGameState === '1') {
        scoreLine = new Intl.DateTimeFormat('default', {
            hour: 'numeric',
            minute: 'numeric'
        }).format(new Date(game.gameDate));
    } else if (game.linescore) {
        const awayTeam = game.linescore.teams.away;
        const homeTeam = game.linescore.teams.home;
        
        if (awayTeam.goals > homeTeam.goals) {
            scoreLine = `<b>${this.getTeamAbbreviation(awayTeam.team.id)} ${awayTeam.goals}</b>, ${this.getTeamAbbreviation(homeTeam.team.id)} ${homeTeam.goals}`;
        } else {
            scoreLine = `<b>${this.getTeamAbbreviation(homeTeam.team.id)} ${homeTeam.goals}</b>, ${this.getTeamAbbreviation(awayTeam.team.id)} ${awayTeam.goals}`;
        }
    }
}

Next, I need to ensure that the date attribute is respected, and can be changed by via Javascript. We already rerender whenever the date attribute changes (since it is in the observedAttributes array, and we render in the attributeChangedCallback callback). However, we can't check the current attribute in the constructor (where we were setting this.date), again because of requirements from the custom elements spec:

  • The element's attributes and children must not be inspected, as in the non-upgrade case none will be present, and relying on upgrades makes the element less usable.
  • The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods.

My solution is to create a date getter that returns the attribute or a default:

get date() {
    return this.getAttribute('date') || NHLSchedule.currentDate();
}

The only additional change is to ensure we refetch data for the new date.

attributeChangedCallback(property, oldValue, newValue) {
    if (property === 'date' && oldValue !== newValue) {
      this.fetchSchedule();
    }

    this.render();
  }

And here is the codepen with all the fixes in place!

See the Pen NHL Schedule Custom Element - Date Change by Nathan Gingrich (@njgingrich) on CodePen.

Next Steps

There's a few improvements I can make to what I've done so far:

  • Styling
    • Currently it looks bad, because I have spent exactly 0 minutes adding any styles to it.
  • Team selector
    • Adding a team attribute would be fun, to show the schedule for just a single team.
  • Date range
    • This component would be a lot more useful if you could specify a date range, to show (for example) next week's games. Combos well with the previous improvement as well.

Conclusion

Honestly, this was much simpler than I thought it would be. I have a much better understanding of custom elements and their lifecycles now, and it feels good to have built a little widget! I'm excited to double down and make some more improvements, and also build more custom elements that use other parts of the NHL API (live games, anyone?).