diff --git a/CHANGELOG.md b/CHANGELOG.md index 20dbd129..aabfbc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # MMM-NHL Changelog +## [2.4.0] + +Thanks to @parnic @dannoh for their contributions. + +### Fixed + +* Updated module to work with the new NHL API. + ## [2.3.1] Thanks to @parnic @dannoh @timpeer for their contributions. diff --git a/MMM-NHL.js b/MMM-NHL.js index 79811320..d1937cc6 100644 --- a/MMM-NHL.js +++ b/MMM-NHL.js @@ -31,9 +31,9 @@ Module.register('MMM-NHL', { * @member {object.} modes - Maps mode short codes to names. */ modes: { - PR: 'Pre-season', - R: 'Regular season', - P: 'Playoffs', + 1: 'Pre-season', + 2: 'Regular season', + 3: 'Playoffs', }, /** diff --git a/node_helper.js b/node_helper.js index 515528b7..1ad6a8af 100644 --- a/node_helper.js +++ b/node_helper.js @@ -15,12 +15,6 @@ */ const fetch = require('node-fetch'); -/** - * @external querystring - * @see https://nodejs.org/api/querystring.html - */ -const qs = require('querystring'); - /** * @external logger * @see https://github.com/MichMich/MagicMirror/blob/master/js/logger.js @@ -33,7 +27,6 @@ const Log = require('logger'); */ const NodeHelper = require('node_helper'); -const BASE_URL = 'https://statsapi.web.nhl.com/api/v1'; const BASE_PLAYOFF_URL = 'https://statsapi.web.nhl.com/api/v1/tournaments/playoffs?expand=round.series'; /** @@ -41,8 +34,7 @@ const BASE_PLAYOFF_URL = 'https://statsapi.web.nhl.com/api/v1/tournaments/playof * * @typedef {object} Team * @property {number} id - Team identifier. - * @property {string} name - Full team name. - * @property {string} short - 3 letter team name. + * @property {string} abbrev - 3 letter team name. * @property {number} score - Current score of the team. */ @@ -53,25 +45,25 @@ const BASE_PLAYOFF_URL = 'https://statsapi.web.nhl.com/api/v1/tournaments/playof * @property {number} id - Game identifier. * @property {string} timestamp - Start date of the game in UTC timezone. * @property {string} gameDay - Game day in format YYYY-MM-DD in north american timezone. - * @property {object} status - Contains information about the game status. - * @property {string} status.abstract - Abstract game status e.g. Preview, Live or Final. - * @property {string} status.detailed - More detailed version of the abstract game status. - * @property {object} teams - Contains information about both teams. - * @property {Team} teams.away - Contains information about the away team. - * @property {Team} teams.home - Contains information about the home team. - * @property {object} live - Contains information about the live state of the game. - * @property {string} live.period - Period of the game e.g. 1st, 2nd, 3rd, OT or SO. - * @property {string} live.timeRemaining - Remaining time of the current period in format mm:ss. + * @property {string} gameState - Contains information about the game status, e.g. OFF, LIVE, CRIT, FUT. + * @property {Team} awayTeam - Contains information about the away team. + * @property {Team} homeTeam - Contains information about the home team. + * @property {object} periodDescriptor - Contains information about the period of play of the game. Is present on all games, past, present, and future. + * @property {number} periodDescriptor.number - Period of the game e.g. 1, 2, 3, 4. + * @property {string} periodDescriptor.periodType - Abbreviated description of the period type, e.g. REG, OT. */ /** * Derived game details from API endpoint for easier usage. * * @typedef {object} Series - * @property {number} number - Game identifier. - * @property {number} round - Start date of the game in UTC timezone. - * @property {Team} teams.away - Contains information about the away team. - * @property {Team} teams.home - Contains information about the home team. + * @property {number} gameNumberOfSeries - Game identifier. + * @property {number} round - Playoff round number, e.g. 1, 2, 3, 4. + * @property {string} roundAbbrev - Abbreviation of round type, e.g. SCF + * @property {number} topSeedTeamId - Contains the ID of the top-seeded team. + * @property {number} topSeedWins - Contains the number of wins of the top-seeded team in this round. + * @property {number} bottomSeedTeamId - Contains the ID of the bottom-seeded team. + * @property {number} bottomSeedWins - Contains the number of wins of the bottom-seed team in this round. */ /** @@ -79,7 +71,7 @@ const BASE_PLAYOFF_URL = 'https://statsapi.web.nhl.com/api/v1/tournaments/playof * * @typedef {object} SeasonDetails * @property {string} year - Year of the season in format yy/yy e.g. 20/21. - * @property {string} mode - Mode of the season e.g. PR, R and P. + * @property {number} mode - Mode of the season e.g. 0, 1 and 2. */ /** @@ -87,7 +79,6 @@ const BASE_PLAYOFF_URL = 'https://statsapi.web.nhl.com/api/v1/tournaments/playof * @description Backend for the module to query data from the API provider. * * @requires external:node-fetch - * @requires external:querystring * @requires external:logger * @requires external:node_helper */ @@ -98,6 +89,8 @@ module.exports = NodeHelper.create({ nextGame: null, /** @member {Game[]} liveGames - List of all ongoing games. */ liveGames: [], + /** @member {Game[]} liveStates - List of all live game states. */ + liveStates: ['LIVE', 'CRIT'], /** * @function socketNotificationReceived @@ -130,7 +123,11 @@ module.exports = NodeHelper.create({ * @returns {void} */ async initTeams() { - const response = await fetch(`${BASE_URL}/teams`); + if (this.teamMapping) { + return; + } + + const response = await fetch(`https://api.nhle.com/stats/rest/en/team`); if (!response.ok) { Log.error(`Initializing NHL teams failed: ${response.status} ${response.statusText}`); @@ -138,15 +135,90 @@ module.exports = NodeHelper.create({ return; } - const { teams } = await response.json(); + const { data } = await response.json(); - this.teamMapping = teams.reduce((mapping, team) => { - mapping[team.id] = team.abbreviation; + this.teamMapping = data.reduce((mapping, team) => { + mapping[team.id] = { short: team.triCode, name: team.fullName }; return mapping; }, {}); }, + /** + * @function getScheduleDates + * @description Helper function to retrieve dates in the past and future based on config options. + * @async + * + * @returns {object} Dates in the past and future. + */ + getScheduleDates() { + const start = new Date(); + start.setDate(start.getDate() - this.config.daysInPast); + + const end = new Date(); + end.setDate(end.getDate() + this.config.daysAhead + 1); + end.setHours(0); + end.setMinutes(0); + end.setSeconds(0); + + const today = new Date(); + + return { + startUtc: start.toISOString(), + startFormatted: new Intl.DateTimeFormat('fr-ca', { timeZone: 'America/Toronto' }).format(start), + endUtc: end.toISOString(), + endFormatted: new Intl.DateTimeFormat('fr-ca', { timeZone: 'America/Toronto' }).format(end), + todayUtc: today.toISOString(), + todayFormatted: new Intl.DateTimeFormat('fr-ca', { timeZone: 'America/Toronto' }).format(today) + }; + }, + + /** + * @function getRemainingGameTime + * @description Helper function to retrieve remaining game time. + * @async + * + * @returns {string?} Remaining game time. + */ + getRemainingGameTime(game, scores) { + if (!this.liveStates.includes(game.gameState)) { + return; + } + + const score = scores.find(score => score.id === game.id); + if (!score) { + return; + } + + return score?.clock?.inIntermission ? '00:00' : score?.clock?.timeRemaining; + }, + + /** + * @function hydrateRemainingTime + * @description Hydrates remaining time on the games in the schedule from the scores API endpoint. + * @async + * + * @returns {object[]} Raw games from API endpoint including remaining time. + */ + async hydrateRemainingTime(schedule) { + const { todayFormatted } = this.getScheduleDates(); + const scoresUrl = `https://api-web.nhle.com/v1/score/${todayFormatted}`; + const scoresResponse = await fetch(scoresUrl); + if (!scoresResponse.ok) { + Log.error(`Fetching NHL scores failed: ${scoresResponse.status} ${scoresResponse.statusText}. Url: ${scoresUrl}`); + + return schedule; + } + + const { games } = await scoresResponse.json(); + + for (const game of schedule) { + game.timeRemaining = this.getRemainingGameTime(game, games); + } + + return schedule; + }, + /** * @function fetchSchedule * @description Retrieves a list of games from the API with timespan based on config options. @@ -155,27 +227,22 @@ module.exports = NodeHelper.create({ * @returns {object[]} Raw games from API endpoint. */ async fetchSchedule() { - const date = new Date(); - date.setDate(date.getDate() - this.config.daysInPast); - const startDate = new Intl.DateTimeFormat('fr-ca', { timeZone: 'America/Toronto' }) - .format(date); + const { startFormatted, endUtc } = this.getScheduleDates(); - date.setDate(date.getDate() + this.config.daysInPast + this.config.daysAhead); - const endDate = new Intl.DateTimeFormat('fr-ca', { timeZone: 'America/Toronto' }) - .format(date); - - const query = qs.stringify({ startDate, endDate, expand: 'schedule.linescore' }); - const url = `${BASE_URL}/schedule?${query}`; - const response = await fetch(url); - - if (!response.ok) { - Log.error(`Fetching NHL schedule failed: ${response.status} ${response.statusText}. Url: ${url}`); + const scheduleUrl = `https://api-web.nhle.com/v1/schedule/${startFormatted}`; + const scheduleResponse = await fetch(scheduleUrl); + if (!scheduleResponse.ok) { + Log.error(`Fetching NHL schedule failed: ${scheduleResponse.status} ${scheduleResponse.statusText}. Url: ${scheduleUrl}`); return; } - const { dates } = await response.json(); + const { gameWeek } = await scheduleResponse.json(); + + const schedule = gameWeek.map(({ date, games }) => games.filter(game => game.startTimeUTC < endUtc).map(game => ({ ...game, gameDay: date }))).flat(); - return dates.map(({ date, games }) => games.map(game => ({ ...game, gameDay: date }))).flat(); + const scheduleWithRemainingTime = await this.hydrateRemainingTime(schedule); + + return scheduleWithRemainingTime; }, /** @@ -186,6 +253,7 @@ module.exports = NodeHelper.create({ * @returns {object} Raw playoff data from API endpoint. */ async fetchPlayoffs() { + // TODO: Find playoff endpoints in new API const response = await fetch(BASE_PLAYOFF_URL); if (!response.ok) { @@ -195,6 +263,7 @@ module.exports = NodeHelper.create({ const playoffs = await response.json(); playoffs.rounds.sort((a, b) => a.number <= b.number ? 1 : -1); + return playoffs; }, @@ -212,8 +281,8 @@ module.exports = NodeHelper.create({ return true; } - const homeTeam = this.teamMapping[game.teams.home.team.id]; - const awayTeam = this.teamMapping[game.teams.away.team.id]; + const homeTeam = this.teamMapping[game.homeTeam.id].short; + const awayTeam = this.teamMapping[game.awayTeam.id].short; return focus.includes(homeTeam) || focus.includes(awayTeam); }, @@ -238,9 +307,9 @@ module.exports = NodeHelper.create({ const today = games.filter(game => game.gameDay === date); const tomorrow = games.filter(game => game.gameDay > date); - const ongoingStates = ['Final', 'Live']; + const ongoingStates = ['OFF', 'CRIT', 'LIVE']; - if (today.some(game => ongoingStates.includes(game.status.abstract))) { + if (today.some(game => ongoingStates.includes(game.status))) { return [...today, ...tomorrow]; } @@ -256,11 +325,11 @@ module.exports = NodeHelper.create({ * @returns {SeasonDetails} Current season details. */ computeSeasonDetails(schedule) { - const game = schedule.find(game => game.status.abstractGameState !== 'Final') || schedule[schedule.length - 1]; + const game = schedule.find(game => game.gameState !== 'OFF') || schedule[schedule.length - 1]; if (game) { return { - year: `${game.season.slice(2, 4)}/${game.season.slice(6, 8)}`, + year: `${game.season.toString().slice(2, 4)}/${game.season.toString().slice(6, 8)}`, mode: game.gameType }; } @@ -271,7 +340,7 @@ module.exports = NodeHelper.create({ return { year: `${currentYear}/${nextYear}`, - mode: 'PR' + mode: 1 }; }, @@ -287,6 +356,7 @@ module.exports = NodeHelper.create({ if (!playoffData || !playoffData.rounds) { return []; } + const series = []; playoffData.rounds.forEach(r => { r.series.forEach(s => { @@ -296,6 +366,7 @@ module.exports = NodeHelper.create({ } }); }); + return series; }, @@ -303,22 +374,21 @@ module.exports = NodeHelper.create({ * @function parseTeam * @description Transforms raw team information for easier usage. * - * @param {object} teams - Both teams in raw format. - * @param {string} type - Type of team: away or home. + * @param {object} team - Team in raw format. * * @returns {Team} Parsed team information. */ - parseTeam(teams = {}, type) { - const team = teams[type]; + parseTeam(team) { if (!team) { - Log.error({ NoTeamFound: teams, type }); + Log.error('no team given'); return {}; } + return { - id: team.team.id, - name: team.team.name, - short: this.teamMapping[team.team.id], - score: team.score + id: team.id, + name: this.teamMapping[team.id].name, + short: this.teamMapping[team.id].short, + score: team.score ?? 0 }; }, @@ -326,15 +396,21 @@ module.exports = NodeHelper.create({ * @function parsePlayoffTeam * @description Transforms raw game information for easier usage. * - * @param {object} teamData - Raw game information. + * @param {object} rawTeam - Raw team information. * - * @param {number} index - Which index of teamData to operate on. + * @param {object} game - Raw game information. * * @returns {Game} Parsed game information. */ - parsePlayoffTeam(teamData = {}, index) { - const team = this.parseTeam(teamData, index); - team.score = teamData[index].seriesRecord.wins; + parsePlayoffTeam(rawTeam, game) { + const team = this.parseTeam(rawTeam); + + if (game?.seriesStatus?.topSeedTeamId === team.id) { + team.score = game?.seriesStatus?.topSeedWins; + } else { + team.score = game?.seriesStatus?.bottomSeedWins; + } + return team; }, @@ -348,24 +424,38 @@ module.exports = NodeHelper.create({ */ parseGame(game = {}) { return { - id: game.gamePk, - timestamp: game.gameDate, + id: game.id, + timestamp: game.startTimeUTC, gameDay: game.gameDay, - status: { - abstract: game.status.abstractGameState, - detailed: game.status.detailedState - }, + status: game.gameState, teams: { - away: this.parseTeam(game.teams, 'away'), - home: this.parseTeam(game.teams, 'home') + away: this.parseTeam(game.awayTeam), + home: this.parseTeam(game.homeTeam) }, live: { - period: game.linescore.currentPeriodOrdinal, - timeRemaining: game.linescore.currentPeriodTimeRemaining + period: this.getNumberWithOrdinal(game.periodDescriptor.number), + periodType: game.periodDescriptor.periodType, + timeRemaining: game.timeRemaining, } }; }, + /** + * @function getNumberWithOrdinal + * @description Converts a raw number into a number with appropriate English ordinal suffix. + * + * @param {number} n - The number to apply an ordinal suffix to. + * + * @returns {string} The given number with its ordinal suffix appended. + */ + getNumberWithOrdinal(n) { + // TODO: This function seems over complicated, don't we just have 1st 2nd and 3rd? + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + + return n + (s[(v - 20) % 10] || s[v] || s[0]); + }, + /** * @function parseSeries * @description Transforms raw series information for easier usage. @@ -378,12 +468,13 @@ module.exports = NodeHelper.create({ if (!series.matchupTeams || series.matchupTeams.length === 0) { return null; } + return { number: series.number, round: series.round.number, teams: { - home: this.parsePlayoffTeam(series.matchupTeams, 0), - away: this.parsePlayoffTeam(series.matchupTeams, 1), + home: this.parsePlayoffTeam(series.matchupTeams, undefined), // TODO: Don't pass undefined to retrieve the correct score + away: this.parsePlayoffTeam(series.matchupTeams, undefined), // TODO: Don't pass undefined to retrieve the correct score } } }, @@ -397,8 +488,8 @@ module.exports = NodeHelper.create({ * @returns {void} */ setNextandLiveGames(games) { - this.nextGame = games.find(game => game.status.abstract === 'Preview'); - this.liveGames = games.filter(game => game.status.abstract === 'Live'); + this.nextGame = games.find(game => game.status === 'FUT'); + this.liveGames = games.filter(game => this.liveStates.includes(game.status)); }, /** @@ -411,11 +502,11 @@ module.exports = NodeHelper.create({ * @returns {number} Should game be before or after in the list? */ sortGamesByTimestampAndID(game1, game2) { - if (game1.gameDate === game2.gameDate) { + if (game1.startTimeUTC === game2.startTimeUTC) { return game1.id > game2.id ? 1 : -1; } - return game1.gameDate > game2.gameDate ? 1 : -1; + return game1.startTimeUTC > game2.startTimeUTC ? 1 : -1; }, /** @@ -438,7 +529,8 @@ module.exports = NodeHelper.create({ this.setNextandLiveGames(rollOverGames); this.sendSocketNotification('SCHEDULE', { games: rollOverGames, season }); - if (season.mode === 'P' || games.length === 0) { + + if (season.mode === 3 || games.length === 0) { const playoffData = await this.fetchPlayoffs(); const playoffSeries = this.computePlayoffDetails(playoffData).filter(s => s.round >= playoffData.defaultRound); @@ -456,7 +548,7 @@ module.exports = NodeHelper.create({ */ fetchOnLiveState() { const hasLiveGames = this.liveGames.length > 0; - const gameAboutToStart = this.nextGame && new Date() > new Date(this.nextGame.timestamp); + const gameAboutToStart = this.nextGame && new Date().toISOString() > this.nextGame.timestamp; if (hasLiveGames || gameAboutToStart) { return this.updateSchedule(); diff --git a/templates/MMM-NHL.njk b/templates/MMM-NHL.njk index 0208f7fd..4f492bbe 100644 --- a/templates/MMM-NHL.njk +++ b/templates/MMM-NHL.njk @@ -19,26 +19,27 @@ {% for index in range(rotateIndex, maxGames) %} - {% if games[index].status.detailed === "Pre-Game" %} + {% if games[index].status === "PRE" %} {{ "PRE_GAME" | translate }} - {% elif games[index].status.detailed === "Postponed" %} + {# TODO: Find out what the state postponed state is in the new API #} + {% elif games[index].status === "Postponed" %} {{ "POSTPONED" | translate }} - {% elif games[index].status.abstract === "Preview" %} + {% elif games[index].status === "FUT" %} {{ games[index] | formatStartDate }} - {% elif games[index].status.abstract === "Live" and games[index].live.period %} -
{{ games[index].live.period | translate }}
-
- {% if games[index].live.timeRemaining === "Final" %} - {{ "FINAL" | translate }} - {% else %} + {% elif (games[index].status === "LIVE" or games[index].status === "CRIT") and games[index].live.period %} + {% if games[index].live.timeRemaining %} +
{{ games[index].live.period | translate }}
+
{{ "TIME_LEFT" | translate({TIME: games[index].live.timeRemaining}) }} - {% endif %} -
+
+ {% else %} + {{ games[index].live.period | translate }} + {% endif %} {% else %} {% if games[index].live.period === '3rd' %} {{ "FINAL" | translate }} {% else %} - {{ ("FINAL_" + games[index].live.period) | translate }} + {{ ("FINAL_" + games[index].live.periodType) | translate }} {% endif %} {% endif %}