diff --git a/MMM-MyScoreboard.css b/MMM-MyScoreboard.css
index 5e6cab1..e198e5a 100644
--- a/MMM-MyScoreboard.css
+++ b/MMM-MyScoreboard.css
@@ -1230,6 +1230,519 @@
}
+/* Football Live Detail */
+
+/* Box heights (compact: no dead space above the logos) */
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos {
+ height: 72px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos {
+ height: 56px;
+}
+
+/* Extra height when broadcast is shown */
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos {
+ height: 92px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos {
+ height: 76px;
+}
+
+/* Room for the play text when the field is hidden (touchdown/PAT, halftime) */
+.MMM-MyScoreboard .box-score.football-has-playtext.largeLogos {
+ height: 88px;
+}
+.MMM-MyScoreboard .box-score.football-has-playtext.mediumLogos {
+ height: 70px;
+}
+.MMM-MyScoreboard .box-score.football-has-playtext.football-has-broadcast.largeLogos {
+ height: 108px;
+}
+.MMM-MyScoreboard .box-score.football-has-playtext.football-has-broadcast.mediumLogos {
+ height: 90px;
+}
+
+/* Clock takes the center row, centerline aligned with the scores */
+.MMM-MyScoreboard .box-score.football-live-detail .status {
+ height: auto;
+ top: 23px;
+ font-size: 20px;
+ font-weight: 400;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.football-has-broadcast .status {
+ top: 27px;
+}
+
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .status {
+ top: 16px;
+ font-size: 17px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.football-has-broadcast.mediumLogos .status {
+ top: 20px;
+}
+
+/* Hide empty broadcast in football detail; position at bottom when present */
+.MMM-MyScoreboard .box-score.football-live-detail .broadcast {
+ display: none;
+}
+
+.MMM-MyScoreboard .box-score.football-has-broadcast .broadcast {
+ display: flex;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ justify-content: center;
+}
+
+/* Pin logos and scores to the top of the compact box */
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos .logo {
+ top: 2px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos .score {
+ top: 17px;
+}
+
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .logo {
+ top: 6px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .score {
+ top: 12px;
+}
+
+/* Nudge down slightly when broadcast is also shown */
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos .logo {
+ top: 6px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos .score {
+ top: 21px;
+}
+
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .logo {
+ top: 10px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .score {
+ top: 16px;
+}
+
+/* Detail container: centered column between the score columns */
+.MMM-MyScoreboard .football-detail {
+ position: absolute;
+ top: 26px;
+ bottom: 2px;
+ left: 110px;
+ right: 110px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ color: #CCC;
+ overflow: hidden;
+}
+
+/* Reserve space at bottom when broadcast is shown */
+.MMM-MyScoreboard .box-score.football-has-broadcast .football-detail {
+ top: 29px;
+ bottom: 21px;
+}
+
+.MMM-MyScoreboard .box-score.mediumLogos .football-detail {
+ left: 92px;
+ right: 92px;
+ top: 12px;
+ bottom: 2px;
+}
+
+/* Top row: down & distance + field position */
+.MMM-MyScoreboard .football-detail-top {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ font-size: 15px;
+ font-weight: bold;
+ color: #FFF;
+}
+
+.MMM-MyScoreboard .football-field-position {
+ font-weight: 400;
+ color: #CCC;
+}
+
+.MMM-MyScoreboard .wrapper.colored .football-detail-top .football-down-distance {
+ color: #f8dd70;
+}
+
+/* Red zone accent: white emphasis in mono, red when colored */
+.MMM-MyScoreboard .box-score.red-zone .football-field-position {
+ color: #FFF;
+ font-weight: bold;
+}
+.MMM-MyScoreboard .wrapper.colored .box-score.red-zone .football-field-position {
+ color: #ff5a5a;
+}
+
+.MMM-MyScoreboard .box-score.red-zone .possession-indicator {
+ background-color: #FFF;
+}
+.MMM-MyScoreboard .wrapper.colored .box-score.red-zone .possession-indicator {
+ background-color: #ff5a5a;
+}
+
+/* Gridiron field graphic */
+
+/* Taller box when the field is shown (halftime keeps the base heights) */
+.MMM-MyScoreboard .box-score.football-has-field.largeLogos {
+ height: 120px;
+}
+.MMM-MyScoreboard .box-score.football-has-field.mediumLogos {
+ height: 96px;
+}
+.MMM-MyScoreboard .box-score.football-has-field.football-has-broadcast.largeLogos {
+ height: 142px;
+}
+.MMM-MyScoreboard .box-score.football-has-field.football-has-broadcast.mediumLogos {
+ height: 118px;
+}
+
+/* Yards row (down & distance + field position) drops to the timeout-dot
+ level, centered between the two timeout columns; clock owns the old spot */
+.MMM-MyScoreboard .box-score.football-has-field .football-detail {
+ justify-content: flex-start;
+ top: 47px;
+ bottom: auto;
+}
+.MMM-MyScoreboard .box-score.football-has-field.football-has-broadcast .football-detail {
+ top: 51px;
+ bottom: auto;
+}
+.MMM-MyScoreboard .box-score.football-has-field.mediumLogos .football-detail {
+ top: 36px;
+ bottom: auto;
+}
+.MMM-MyScoreboard .box-score.football-has-field.football-has-broadcast.mediumLogos .football-detail {
+ top: 40px;
+}
+
+.MMM-MyScoreboard .football-field {
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ bottom: 18px;
+ height: 26px;
+ box-sizing: border-box;
+ display: flex;
+ overflow: hidden;
+ background-color: #242424;
+ border: 1px solid #333;
+ border-radius: 3px;
+}
+.MMM-MyScoreboard .wrapper.colored .football-field {
+ background-color: #1e3324;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast .football-field {
+ bottom: 38px;
+}
+.MMM-MyScoreboard .box-score.mediumLogos .football-field {
+ left: 6px;
+ right: 6px;
+ height: 20px;
+ bottom: 17px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .football-field {
+ bottom: 37px;
+}
+
+/* Static last-play description under the field */
+.MMM-MyScoreboard .football-play-text {
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ bottom: 2px;
+ font-size: 12px;
+ line-height: 1;
+ color: #CCC;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast .football-play-text {
+ bottom: 22px;
+}
+.MMM-MyScoreboard .box-score.mediumLogos .football-play-text {
+ left: 6px;
+ right: 6px;
+ font-size: 11px;
+}
+
+/* End zones: fixed bands outside the 0-100% play scale; inner border = goal line */
+.MMM-MyScoreboard .field-endzone {
+ position: relative;
+ width: 10px;
+ flex-shrink: 0;
+ background-color: #2e2e2e;
+}
+
+/* Touchdown: ball parked in the scoring end zone */
+.MMM-MyScoreboard .field-endzone .field-ball.in-endzone {
+ left: 50%;
+ top: 50%;
+}
+.MMM-MyScoreboard .field-endzone.visitor {
+ border-right: 1px solid #666;
+}
+.MMM-MyScoreboard .field-endzone.home {
+ border-left: 1px solid #666;
+}
+.MMM-MyScoreboard .box-score.mediumLogos .field-endzone {
+ width: 8px;
+}
+
+/* Play area: 0% = visitor goal line, 100% = home goal line; hashes every 10 yards */
+.MMM-MyScoreboard .field-play-area {
+ position: relative;
+ flex: 1;
+ background-image: repeating-linear-gradient(90deg,
+ rgba(255, 255, 255, 0.12) 0,
+ rgba(255, 255, 255, 0.12) 1px,
+ transparent 1px,
+ transparent 10%);
+}
+
+.MMM-MyScoreboard .field-first-down {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ margin-left: -1px;
+ background-color: #CCC;
+ opacity: 0.9;
+}
+.MMM-MyScoreboard .wrapper.colored .field-first-down {
+ background-color: #ffe14d;
+}
+
+/* On a new set of downs the marker stays at the old line-to-gain while the
+ ball slides, then jumps to the new spot once the ball has crossed */
+.MMM-MyScoreboard .field-first-down.hold {
+ animation: msb-fb-fd-hold 1.3s linear;
+}
+@keyframes msb-fb-fd-hold {
+ 0% {
+ left: var(--fd-prev-x);
+ }
+ 99.9% {
+ left: var(--fd-prev-x);
+ }
+ 100% {
+ left: var(--fd-x);
+ }
+}
+
+/* Goal-to-go reached: hold the old marker through the slide, then vanish
+ (the goal line itself becomes the line-to-gain) */
+.MMM-MyScoreboard .field-first-down.hold-out {
+ animation: msb-fb-fd-hold-out 1.3s linear forwards;
+}
+@keyframes msb-fb-fd-hold-out {
+ 0% {
+ left: var(--fd-prev-x);
+ opacity: 0.9;
+ }
+ 99.9% {
+ left: var(--fd-prev-x);
+ opacity: 0.9;
+ }
+ 100% {
+ left: var(--fd-prev-x);
+ opacity: 0;
+ }
+}
+
+.MMM-MyScoreboard .field-ball {
+ position: absolute;
+ top: 50%;
+ left: var(--ball-x);
+ width: 12px;
+ height: 7px;
+ margin: -3.5px 0 0 -6px;
+ border-radius: 50%;
+ background-color: #CCC;
+ z-index: 2;
+}
+.MMM-MyScoreboard .box-score.mediumLogos .field-ball {
+ width: 10px;
+ height: 6px;
+ margin: -3px 0 0 -5px;
+}
+.MMM-MyScoreboard .field-ball.slide {
+ animation: msb-fb-ball-slide 1s ease;
+}
+@keyframes msb-fb-ball-slide {
+ from {
+ left: var(--ball-prev-x);
+ }
+ to {
+ left: var(--ball-x);
+ }
+}
+
+.MMM-MyScoreboard .field-trail {
+ position: absolute;
+ top: 25%;
+ bottom: 25%;
+ z-index: 1;
+ animation: msb-fb-trail-fade 2s ease-out forwards;
+}
+.MMM-MyScoreboard .field-trail.gain {
+ background-color: rgba(255, 255, 255, 0.5);
+}
+.MMM-MyScoreboard .field-trail.loss {
+ background-color: rgba(110, 110, 110, 0.6);
+}
+.MMM-MyScoreboard .wrapper.colored .field-trail.gain {
+ background-color: rgba(90, 220, 110, 0.55);
+}
+.MMM-MyScoreboard .wrapper.colored .field-trail.loss {
+ background-color: rgba(255, 90, 90, 0.55);
+}
+@keyframes msb-fb-trail-fade {
+ 0% {
+ opacity: 1;
+ }
+ 60% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+.MMM-MyScoreboard .wrapper.colored .field-ball {
+ background-color: #f8dd70;
+}
+.MMM-MyScoreboard .box-score.red-zone .field-ball {
+ background-color: #FFF;
+}
+.MMM-MyScoreboard .wrapper.colored .box-score.red-zone .field-ball {
+ background-color: #ff5a5a;
+}
+
+/* Possession indicator: CSS-drawn football centered under the possessing team's logo */
+.MMM-MyScoreboard .possession-indicator {
+ position: absolute;
+ width: 15px;
+ height: 9px;
+ border-radius: 50%;
+ background-color: #CCC;
+}
+
+.MMM-MyScoreboard .possession-indicator::after {
+ content: '';
+ position: absolute;
+ left: 4px;
+ right: 4px;
+ top: 4px;
+ height: 1px;
+ background-color: #000;
+}
+
+.MMM-MyScoreboard .wrapper.colored .possession-indicator {
+ background-color: #f8dd70;
+}
+
+/* Logos are 60px wide at the box edges (40px in mediumLogos).
+ Icon sits directly below the logo, clear of the field graphic. */
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos .possession-indicator.home {
+ right: 22px;
+ top: 63px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos .possession-indicator.visitor {
+ left: 22px;
+ top: 63px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos .possession-indicator.home,
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos .possession-indicator.visitor {
+ top: 67px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .possession-indicator.home {
+ right: 12px;
+ top: 47px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .possession-indicator.visitor {
+ left: 12px;
+ top: 47px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .possession-indicator.home,
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .possession-indicator.visitor {
+ top: 51px;
+}
+
+/* Timeouts: three dots centered under each score column (width 44px at left/right 62px) */
+.MMM-MyScoreboard .football-timeouts {
+ position: absolute;
+ display: flex;
+ gap: 3px;
+ width: 44px;
+ justify-content: center;
+}
+
+.MMM-MyScoreboard .football-timeouts.home {
+ right: 62px;
+}
+.MMM-MyScoreboard .football-timeouts.visitor {
+ left: 62px;
+}
+
+.MMM-MyScoreboard .box-score.football-live-detail.largeLogos .football-timeouts {
+ top: 54px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.largeLogos .football-timeouts {
+ top: 58px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .football-timeouts {
+ width: 40px;
+ top: 42px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .football-timeouts.home {
+ right: 42px;
+}
+.MMM-MyScoreboard .box-score.football-live-detail.mediumLogos .football-timeouts.visitor {
+ left: 42px;
+}
+.MMM-MyScoreboard .box-score.football-has-broadcast.mediumLogos .football-timeouts {
+ top: 46px;
+}
+
+.MMM-MyScoreboard .football-timeouts .timeout-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ border: 1px solid #666;
+ background-color: transparent;
+}
+
+.MMM-MyScoreboard .football-timeouts .timeout-dot.active {
+ background-color: #CCC;
+ border-color: #CCC;
+}
+
+.MMM-MyScoreboard .wrapper.colored .football-timeouts .timeout-dot.active {
+ background-color: #f8dd70;
+ border-color: #f8dd70;
+}
+
+/* mediumLogos scaling */
+.MMM-MyScoreboard .box-score.mediumLogos .football-detail-top {
+ font-size: 13px;
+}
+
+
/* Broadcast Logos */
.MMM-MyScoreboard .broadcast {
font-size: 14px;
diff --git a/MMM-MyScoreboard.js b/MMM-MyScoreboard.js
index 03786a3..f362e77 100644
--- a/MMM-MyScoreboard.js
+++ b/MMM-MyScoreboard.js
@@ -34,9 +34,13 @@ Module.register('MMM-MyScoreboard', {
debugHours: 0,
debugMinutes: 0,
showPlayoffStatus: false,
- showBaseballDetail: false,
- baseballDetailInterval: 15,
- baseballDetailViewOverride: true,
+ showDetail: false,
+ detailInterval: 15,
+ detailViewOverride: true,
+ /*
+ Deprecated aliases (mapped onto the unified options in start()):
+ showBaseballDetail, baseballDetailInterval, baseballDetailViewOverride
+ */
showScoreAnimation: false,
showUpcomingGames: false,
sports: [
@@ -319,9 +323,16 @@ Module.register('MMM-MyScoreboard', {
noGamesToday: {},
logoIndex: 0,
baseballLeagues: ['MLB', 'NCAAB', 'WBC'],
+ footballLeagues: ['NFL', 'NCAAF'],
highScoringLeagues: ['NBA', 'NCAAM', 'NCAAM_MM', 'NCAAW', 'WNBA', 'NBAG'],
- baseballFastPollTimer: null,
- baseballFastPollActive: false,
+ fastPollTimers: {},
+ fastPollActive: {},
+ footballFieldState: {},
+ localLogoRetryTimer: null,
+ fastPollSports: [
+ { key: 'baseball', leagues: 'baseballLeagues', show: 'showDetail', interval: 'detailInterval' },
+ { key: 'football', leagues: 'footballLeagues', show: 'showDetail', interval: 'detailInterval' },
+ ],
viewStyleHasLogos: function (v) {
switch (v) {
@@ -432,11 +443,17 @@ Module.register('MMM-MyScoreboard', {
var viewStyle = this.config.viewStyle
// Override viewStyle for active baseball games with detail enabled
- if (this.config.showBaseballDetail && this.config.baseballDetailViewOverride
+ if (this.config.showDetail && this.config.detailViewOverride
&& gameObj.baseballSituation && this.baseballLeagues.includes(league)) {
viewStyle = 'largeLogos'
}
+ // Override viewStyle for active football games with detail enabled
+ if (this.config.showDetail && this.config.detailViewOverride
+ && gameObj.footballSituation && this.footballLeagues.includes(league)) {
+ viewStyle = 'largeLogos'
+ }
+
var boxScore = document.createElement('div')
boxScore.classList.add('box-score', league.replaceAll(' ', ''))
boxScore.classList.add(viewStyle)
@@ -637,7 +654,7 @@ Module.register('MMM-MyScoreboard', {
}
// add baseball detail for active games
- if (this.config.showBaseballDetail && gameObj.baseballSituation) {
+ if (this.config.showDetail && gameObj.baseballSituation) {
var canShowDetail = true
if (['smallLogos', 'oneLine', 'oneLineWithLogos', 'stacked', 'stackedWithLogos'].includes(viewStyle)) {
canShowDetail = false
@@ -720,6 +737,209 @@ Module.register('MMM-MyScoreboard', {
}
}
+ // add football detail for active games
+ var fbGameKey = label + ':' + gameObj.vTeam + '@' + gameObj.hTeam
+ if (this.footballLeagues.includes(league)
+ && (gameObj.gameMode === this.gameModes.FINAL || !gameObj.footballSituation)) {
+ delete this.footballFieldState[fbGameKey]
+ }
+ if (this.config.showDetail && gameObj.footballSituation) {
+ var canShowFbDetail = !['smallLogos', 'oneLine', 'oneLineWithLogos', 'stacked', 'stackedWithLogos'].includes(viewStyle)
+ var fsit = gameObj.footballSituation
+ var hasFbContent = fsit.downDistance || fsit.possessionText
+ || fsit.homeTimeouts !== null || fsit.awayTimeouts !== null || fsit.possession
+
+ if (canShowFbDetail && hasFbContent) {
+ boxScore.classList.add('football-live-detail')
+ if (fsit.isRedZone) boxScore.classList.add('red-zone')
+
+ var fbDetail = document.createElement('div')
+ fbDetail.classList.add('football-detail')
+
+ // Top row: down & distance + field position (empty at halftime / quarter breaks)
+ if (fsit.downDistance || fsit.possessionText) {
+ var fbTop = document.createElement('div')
+ fbTop.classList.add('football-detail-top')
+ if (fsit.downDistance) {
+ var downDistance = document.createElement('span')
+ downDistance.classList.add('football-down-distance')
+ downDistance.innerHTML = fsit.downDistance
+ fbTop.appendChild(downDistance)
+ }
+ if (fsit.possessionText) {
+ var fieldPosition = document.createElement('span')
+ fieldPosition.classList.add('football-field-position')
+ fieldPosition.innerHTML = fsit.possessionText
+ fbTop.appendChild(fieldPosition)
+ }
+ fbDetail.appendChild(fbTop)
+ }
+
+ boxScore.appendChild(fbDetail)
+
+ // Gridiron field graphic with ball spot, first-down line, and
+ // slide/trail animation when the spot changes between updates.
+ // On a touchdown the feed carries no spot: keep the field and park
+ // the ball in the end zone the scoring team reached (persists
+ // through the PAT until the kickoff spot arrives).
+ var prevPos = this.footballFieldState[fbGameKey]
+ var tdSide = null
+ if (typeof fsit.ballX !== 'number') {
+ if (fsit.lastPlay && /touchdown/i.test(fsit.lastPlay) && fsit.possession) {
+ tdSide = fsit.possession === 'home' ? 'visitor' : 'home'
+ }
+ else if (prevPos && prevPos.tdSide) {
+ tdSide = prevPos.tdSide
+ }
+ }
+
+ if (typeof fsit.ballX === 'number' || tdSide) {
+ boxScore.classList.add('football-has-field')
+
+ var field = document.createElement('div')
+ field.classList.add('football-field')
+
+ var ezV = document.createElement('div')
+ ezV.classList.add('field-endzone', 'visitor')
+ field.appendChild(ezV)
+
+ var playArea = document.createElement('div')
+ playArea.classList.add('field-play-area')
+ if (typeof fsit.ballX === 'number') {
+ var willSlide = prevPos && typeof prevPos.x === 'number' && Math.abs(prevPos.x - fsit.ballX) >= 0.5
+ // Turnovers reverse drive direction: no trail, no marker hold
+ var samePossession = willSlide && fsit.possession && prevPos.possession === fsit.possession
+
+ // First-down marker: on a new set of downs, hold the old
+ // line-to-gain while the ball slides, then jump/vanish
+ var prevFd = (samePossession && typeof prevPos.fd === 'number') ? prevPos.fd : null
+ if (fsit.firstDownX !== null) {
+ var fdLine = document.createElement('div')
+ fdLine.classList.add('field-first-down')
+ fdLine.style.left = fsit.firstDownX + '%'
+ if (prevFd !== null && Math.abs(prevFd - fsit.firstDownX) >= 0.5) {
+ fdLine.style.setProperty('--fd-prev-x', prevFd + '%')
+ fdLine.style.setProperty('--fd-x', fsit.firstDownX + '%')
+ fdLine.classList.add('hold')
+ }
+ playArea.appendChild(fdLine)
+ }
+ else if (prevFd !== null) {
+ // Goal-to-go reached: old marker holds through the slide, then fades
+ var fdLineOut = document.createElement('div')
+ fdLineOut.classList.add('field-first-down', 'hold-out')
+ fdLineOut.style.setProperty('--fd-prev-x', prevFd + '%')
+ playArea.appendChild(fdLineOut)
+ }
+
+ var ball = document.createElement('div')
+ ball.classList.add('field-ball')
+ ball.style.setProperty('--ball-x', fsit.ballX + '%')
+ if (willSlide) {
+ // Keyframes referencing the custom props auto-start when MM
+ // attaches the node (same mechanism as the score fireworks)
+ ball.style.setProperty('--ball-prev-x', prevPos.x + '%')
+ ball.classList.add('slide')
+ if (samePossession) {
+ var isGain = fsit.possession === 'home' ? fsit.ballX < prevPos.x : fsit.ballX > prevPos.x
+ var trail = document.createElement('div')
+ trail.classList.add('field-trail', isGain ? 'gain' : 'loss')
+ trail.style.left = Math.min(prevPos.x, fsit.ballX) + '%'
+ trail.style.width = Math.abs(prevPos.x - fsit.ballX) + '%'
+ playArea.appendChild(trail)
+ }
+ }
+ playArea.appendChild(ball)
+ }
+ field.appendChild(playArea)
+
+ var ezH = document.createElement('div')
+ ezH.classList.add('field-endzone', 'home')
+ field.appendChild(ezH)
+
+ // Touchdown: ball ends up in the end zone the scoring team reached.
+ // If the previous spot is known (the scoring play), slide the ball
+ // in from there with a gain trail; later updates (PAT) render it
+ // statically so the animation plays once.
+ if (tdSide) {
+ if (prevPos && typeof prevPos.x === 'number') {
+ var tdBall = document.createElement('div')
+ tdBall.classList.add('field-ball')
+ tdBall.style.setProperty('--ball-prev-x', prevPos.x + '%')
+ tdBall.style.setProperty('--ball-x', tdSide === 'visitor' ? '-5px' : 'calc(100% + 5px)')
+ tdBall.classList.add('slide')
+ if (fsit.possession && prevPos.possession === fsit.possession) {
+ var tdTrail = document.createElement('div')
+ tdTrail.classList.add('field-trail', 'gain')
+ tdTrail.style.left = (tdSide === 'visitor' ? 0 : prevPos.x) + '%'
+ tdTrail.style.width = (tdSide === 'visitor' ? prevPos.x : 100 - prevPos.x) + '%'
+ playArea.appendChild(tdTrail)
+ }
+ playArea.appendChild(tdBall)
+ }
+ else {
+ var tdBallStatic = document.createElement('div')
+ tdBallStatic.classList.add('field-ball', 'in-endzone')
+ ;(tdSide === 'visitor' ? ezV : ezH).appendChild(tdBallStatic)
+ }
+ }
+
+ boxScore.appendChild(field)
+
+ this.footballFieldState[fbGameKey] = tdSide
+ ? { tdSide: tdSide, possession: fsit.possession }
+ : { x: fsit.ballX, possession: fsit.possession, fd: fsit.firstDownX }
+ }
+ else {
+ // Halftime / no parseable spot: no field, reset drive state
+ delete this.footballFieldState[fbGameKey]
+ }
+
+ // Static last-play description at the bottom (truncated, no scroll).
+ // Shown even without a field so touchdown/PAT text stays visible.
+ if (fsit.lastPlay) {
+ if (typeof fsit.ballX !== 'number' && !tdSide) {
+ boxScore.classList.add('football-has-playtext')
+ }
+ var playText = document.createElement('div')
+ playText.classList.add('football-play-text')
+ playText.innerHTML = fsit.lastPlay
+ boxScore.appendChild(playText)
+ }
+
+ // Possession indicator beside the possessing team's logo
+ if (fsit.possession) {
+ var possIcon = document.createElement('span')
+ possIcon.classList.add('possession-indicator', fsit.possession === 'home' ? 'home' : 'visitor')
+ boxScore.appendChild(possIcon)
+ }
+
+ // Timeouts remaining: three dots under each score
+ var buildTimeouts = function (count, side) {
+ if (count === null) return null
+ var wrap = document.createElement('span')
+ wrap.classList.add('football-timeouts', side)
+ for (var t = 0; t < 3; t++) {
+ var dot = document.createElement('span')
+ dot.classList.add('timeout-dot')
+ if (t < count) dot.classList.add('active')
+ wrap.appendChild(dot)
+ }
+ return wrap
+ }
+ var hTimeouts = buildTimeouts(fsit.homeTimeouts, 'home')
+ if (hTimeouts) boxScore.appendChild(hTimeouts)
+ var vTimeouts = buildTimeouts(fsit.awayTimeouts, 'visitor')
+ if (vTimeouts) boxScore.appendChild(vTimeouts)
+
+ // Move broadcast out of status and into boxScore so it can be positioned at the bottom
+ if (gameObj.broadcast != null && gameObj.broadcast.length > 0) {
+ boxScore.classList.add('football-has-broadcast')
+ boxScore.appendChild(broadcastPart)
+ }
+ }
+ }
+
// add classes to final games
if (gameObj.gameMode == this.gameModes.FINAL) {
boxScore.classList.add('final')
@@ -1009,37 +1229,39 @@ Module.register('MMM-MyScoreboard', {
}
}
- // Manage fast polling for active baseball games
- if (this.config.showBaseballDetail) {
- var hasActiveBaseball = false
- var baseballLabels = this.config.sports
- .filter(function (s) { return ['MLB', 'NCAAB', 'WBC'].includes(s.league) })
+ // Manage fast polling for active games in detail-enabled sports
+ this.fastPollSports.forEach(function (fastPollSport) {
+ if (!self.config[fastPollSport.show]) return
+ var fastPollLeagues = self[fastPollSport.leagues]
+ var hasActiveGame = false
+ var fastPollLabels = self.config.sports
+ .filter(function (s) { return fastPollLeagues.includes(s.league) })
.map(function (s) { return s.label || s.league })
- for (var i = 0; i < baseballLabels.length; i++) {
- var labelData = this.sportsData[baseballLabels[i]]
+ for (var i = 0; i < fastPollLabels.length; i++) {
+ var labelData = self.sportsData[fastPollLabels[i]]
if (labelData && labelData.scores) {
for (var j = 0; j < labelData.scores.length; j++) {
- if (labelData.scores[j].gameMode === this.gameModes.IN_PROGRESS) {
- hasActiveBaseball = true
+ if (labelData.scores[j].gameMode === self.gameModes.IN_PROGRESS) {
+ hasActiveGame = true
break
}
}
}
- if (hasActiveBaseball) break
+ if (hasActiveGame) break
}
- if (hasActiveBaseball && !this.baseballFastPollActive) {
- this.baseballFastPollActive = true
- var interval = Math.max(1, this.config.baseballDetailInterval) * 1000
- this.baseballFastPollTimer = setInterval(function () {
- self.getBaseballScoresOnly()
+ if (hasActiveGame && !self.fastPollActive[fastPollSport.key]) {
+ self.fastPollActive[fastPollSport.key] = true
+ var interval = Math.max(1, self.config[fastPollSport.interval]) * 1000
+ self.fastPollTimers[fastPollSport.key] = setInterval(function () {
+ self.getFastPollScoresOnly(fastPollLeagues)
}, interval)
}
- else if (!hasActiveBaseball && this.baseballFastPollActive) {
- clearInterval(this.baseballFastPollTimer)
- this.baseballFastPollTimer = null
- this.baseballFastPollActive = false
+ else if (!hasActiveGame && self.fastPollActive[fastPollSport.key]) {
+ clearInterval(self.fastPollTimers[fastPollSport.key])
+ self.fastPollTimers[fastPollSport.key] = null
+ self.fastPollActive[fastPollSport.key] = false
}
- }
+ })
}
else if (notification === 'MMM-MYSCOREBOARD-SCORE-UPDATE-YD' && payload.instanceId == this.identifier) {
// Log.info('[MMM-MyScoreboard] Updating Yesterday\'s Scores')
@@ -1093,6 +1315,14 @@ Module.register('MMM-MyScoreboard', {
}
}
else if (notification === 'MMM-MYSCOREBOARD-LOCAL-LOGO-LIST' && payload.instanceId == this.identifier) {
+ // Duplicate responses possible (startup request is retried until
+ // answered) — only the first one may set up polling
+ if (this.localLogoRetryTimer === null) {
+ return
+ }
+ clearInterval(this.localLogoRetryTimer)
+ this.localLogoRetryTimer = null
+
this.localLogos = payload.logos
this.localLogosCustom = payload.logosCustom
@@ -1299,6 +1529,21 @@ Module.register('MMM-MyScoreboard', {
start: function () {
Log.info('Starting module: ' + this.name)
+ /*
+ Deprecated option mapping: older configs used baseball-specific
+ detail options. They keep working, but the unified showDetail /
+ detailInterval / detailViewOverride options are the documented ones.
+ */
+ if (this.config.showBaseballDetail) {
+ this.config.showDetail = true
+ }
+ if (typeof this.config.baseballDetailInterval === 'number' && this.config.detailInterval === 15) {
+ this.config.detailInterval = this.config.baseballDetailInterval
+ }
+ if (this.config.baseballDetailViewOverride === false) {
+ this.config.detailViewOverride = false
+ }
+
/*
scrub the config to ensure only supported leagues are included
*/
@@ -1352,10 +1597,16 @@ Module.register('MMM-MyScoreboard', {
Get list of local logo images files.
These will override the URL provided by the feed
- Once this returns the list, we'll start polling for data
+ Once this returns the list, we'll start polling for data.
+ Retry until answered: if this request is emitted before the node
+ helper's socket is bound (slow startup), it is lost and no score
+ polling would ever start.
*/
this.sendSocketNotification('MMM-MYSCOREBOARD-GET-LOCAL-LOGOS', { instanceId: this.identifier })
+ this.localLogoRetryTimer = setInterval(function () {
+ self.sendSocketNotification('MMM-MYSCOREBOARD-GET-LOCAL-LOGOS', { instanceId: self.identifier })
+ }, 2000)
// Schedule the first logo rotation
this.rotateChannels()
@@ -1459,14 +1710,14 @@ Module.register('MMM-MyScoreboard', {
})
},
- getBaseballScoresOnly: function () {
+ getFastPollScoresOnly: function (leagues) {
var gameDate = moment().add(this.config.debugHours, 'hours').add(this.config.debugMinutes, 'minutes')
if (this.config.DEBUG_gameDate) {
gameDate = moment(this.config.DEBUG_gameDate, 'YYYYMMDD')
}
var self = this
this.config.sports.forEach(function (sport, index) {
- if (!self.baseballLeagues.includes(sport.league)) return
+ if (!leagues.includes(sport.league)) return
var thisLabel = sport.label || sport.league
var payload = {
instanceId: self.identifier,
diff --git a/README.md b/README.md
index 75d815a..f1acbd5 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil
config: {
viewStyle: "stackedWithLogos",
showPlayoffStatus: true,
- showBaseballDetail: true,
+ showDetail: true,
sports: [
{
league: "NHL",
@@ -96,8 +96,9 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil
| `maxHeight` | Limit of the height of the container (in pixels). If there are more games than will fit within this height, a scroll animation will start. For best results, set the limit to show exactly the number of games you want when static. This ensures that any extra games will be fully hidden. No animation will occur if there are fewer games than fill the container.
**Type** `Int`
**Default** `10000` (will not set a height limit)
| `scrollSpeed` | Speed for scrolling. _lower_ numbers are _faster_.
**Type** `Int`
**Default** `6`
| `showPlayoffStatus` | Displays information about playoff series when set to `true`. (Currently only works with `stacked` and `stackedWithLogos` views without adding custom css.)
**Type** `Boolean`
**Default** `false`
-| `showBaseballDetail` | When set to `true`, in-progress baseball games (MLB, NCAAB, WBC) display the current game situation: base runners on a diamond indicator, ball-strike count, outs, and the current pitcher/batter matchup. Pitcher and batter are hidden between half-innings.
**Type** `Boolean`
**Default** `false`
-| `baseballDetailInterval` | When `showBaseballDetail` is enabled and a baseball game is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only baseball leagues are polled at this faster rate.
**Type** `Number`
**Default** `15` (seconds, minimum `1`)
+| `showDetail` | When set to `true`, in-progress games in supported sports display the live game situation.
Baseball (MLB, NCAAB, WBC): base runners on a diamond indicator, ball-strike count, outs, and the current pitcher/batter matchup (hidden between half-innings).
Football (NFL, NCAAF): down & distance and field position, an animated gridiron graphic showing the ball spot and first-down line (the ball slides on each new play, with a green/red trail showing yards gained or lost), the last play description below the field, a possession indicator under the logo of the team with the ball, and timeouts remaining under each score. The field position, possession indicator, and ball marker turn red when the offense is in the red zone. On a touchdown the ball slides into the end zone and stays there until the ensuing kickoff.
**Type** `Boolean`
**Default** `false`
+| `detailInterval` | When `showDetail` is enabled and a game in a supported sport is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only leagues of the affected sport are polled at this faster rate.
**Type** `Number`
**Default** `15` (seconds, minimum `1`)
+| `detailViewOverride` | When `showDetail` is enabled, forces the `largeLogos` view for in-progress games showing detail so it has room to render. Set to `false` to keep your configured `viewStyle` (detail only renders in the `largeLogos` and `mediumLogos` views).
**Type** `Boolean`
**Default** `true`
| `showScoreAnimation` | When set to `true`, a firework animation plays on the score element whenever a followed team scores. The animation type varies by sport: low-scoring sports (MLB, NFL, NHL, soccer) get a firework burst, high-scoring sports (NBA, college basketball) get a quick flash, and an extended celebration with multiple staggered fireworks plays when a followed team wins.
**Type** `Boolean`
**Default** `false`
| `showUpcomingGames` | When set to `true`, adds an "Upcoming" section below each sport for every followed team whose today slate is either empty or fully final (MLB doubleheaders are handled — both games must be final). Each row shows the team's next scheduled game with its date and start time, pulled once per team from the league's schedule endpoint and cached for 6 hours. Only applies to followed teams (the `teams` array in a sport config) and only for ESPN-backed leagues (MLB, NFL, NBA, NHL, NCAAF, NCAAM, MLS, most soccer). Non-ESPN providers (CPL, PWHL, SNET) are skipped.
**Type** `Boolean`
**Default** `false`
@@ -105,6 +106,28 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil

+#### Football Detail Examples
+
+Live play — gridiron with ball spot, first-down line, timeouts, and last play:
+
+
+
+Ball sliding to the new spot with a gain trail:
+
+
+
+Red zone — field position, possession indicator, and ball turn red:
+
+
+
+Touchdown — the ball slides into the end zone and sits there until the kickoff:
+
+
+
+With `colored: false`, the field goes full grayscale like the rest of the display:
+
+
+
#### Score Animation Example

diff --git a/footballDetailExample1.png b/footballDetailExample1.png
new file mode 100644
index 0000000..3399b82
Binary files /dev/null and b/footballDetailExample1.png differ
diff --git a/footballDetailExample2.png b/footballDetailExample2.png
new file mode 100644
index 0000000..1fa38fe
Binary files /dev/null and b/footballDetailExample2.png differ
diff --git a/footballDetailExample3.png b/footballDetailExample3.png
new file mode 100644
index 0000000..c681065
Binary files /dev/null and b/footballDetailExample3.png differ
diff --git a/footballDetailExample4.png b/footballDetailExample4.png
new file mode 100644
index 0000000..fb9a6db
Binary files /dev/null and b/footballDetailExample4.png differ
diff --git a/footballDetailExample5.png b/footballDetailExample5.png
new file mode 100644
index 0000000..8a84f98
Binary files /dev/null and b/footballDetailExample5.png differ
diff --git a/node_helper.js b/node_helper.js
index 3e371e8..e999afb 100644
--- a/node_helper.js
+++ b/node_helper.js
@@ -49,6 +49,9 @@ module.exports = NodeHelper.create({
getDirectoryTree(dirPath) {
const result = []
+ if (!fs.existsSync(dirPath)) {
+ return result
+ }
const files = fs.readdirSync(dirPath, { withFileTypes: true })
files.forEach((file) => {
@@ -79,6 +82,7 @@ module.exports = NodeHelper.create({
sport's index from the config's order
*/
+ Log.debug(`[MMM-MyScoreboard] GET-SCORES ${payload.league} whichDay=${JSON.stringify(payload.whichDay)} gameDate=${payload.gameDate}`)
var self = this
var provider = this.providers[payload.provider]
var provider2 = this.providers[payload.provider]
@@ -88,6 +92,7 @@ module.exports = NodeHelper.create({
if (payload.whichDay.today) {
provider.getScores(payload, moment(payload.gameDate), function (scores, sortIdx, noGamesToday) {
+ Log.debug(`[MMM-MyScoreboard] SCORE-UPDATE ${payload.league} games=${scores.length} noGamesToday=${noGamesToday}`)
self.sendSocketNotification('MMM-MYSCOREBOARD-SCORE-UPDATE', { instanceId: payload.instanceId, index: payload.league, scores: scores, label: payload.label, sortIdx: sortIdx, provider: payload.provider, noGamesToday: noGamesToday })
})
}
diff --git a/providers/ESPN.js b/providers/ESPN.js
index eb647c5..c4179e9 100644
--- a/providers/ESPN.js
+++ b/providers/ESPN.js
@@ -1086,6 +1086,37 @@ module.exports = {
}
}
+ // Extract football situation data for in-progress football games
+ var footballSituation = null
+ if (gameState === 1 && leaguePath && leaguePath.startsWith('football/')) {
+ var fbSituation = game.competitions[0].situation
+ if (fbSituation) {
+ var possessionSide = null
+ if (fbSituation.possession != null) {
+ var possId = String(fbSituation.possession)
+ var hId = hTeamData.id != null ? String(hTeamData.id) : (hTeamData.team && hTeamData.team.id != null ? String(hTeamData.team.id) : null)
+ var vId = vTeamData.id != null ? String(vTeamData.id) : (vTeamData.team && vTeamData.team.id != null ? String(vTeamData.team.id) : null)
+ if (hId !== null && possId === hId) possessionSide = 'home'
+ else if (vId !== null && possId === vId) possessionSide = 'away'
+ }
+ var fieldPos = this.computeFieldPosition(
+ fbSituation.possessionText, fbSituation.distance, possessionSide,
+ hTeamData.team && hTeamData.team.abbreviation,
+ vTeamData.team && vTeamData.team.abbreviation)
+ footballSituation = {
+ downDistance: fbSituation.shortDownDistanceText || fbSituation.downDistanceText || '',
+ possessionText: fbSituation.possessionText || '',
+ lastPlay: (fbSituation.lastPlay && fbSituation.lastPlay.text) ? fbSituation.lastPlay.text : '',
+ ballX: fieldPos.ballX,
+ firstDownX: fieldPos.firstDownX,
+ possession: possessionSide,
+ isRedZone: !!fbSituation.isRedZone,
+ homeTimeouts: (typeof fbSituation.homeTimeouts === 'number') ? fbSituation.homeTimeouts : null,
+ awayTimeouts: (typeof fbSituation.awayTimeouts === 'number') ? fbSituation.awayTimeouts : null,
+ }
+ }
+ }
+
if (payload.league !== 'SOCCER_ON_TV' || (broadcast.length > 0)) {
formattedGamesList.push({
classes: classes,
@@ -1104,6 +1135,7 @@ module.exports = {
vTeamLogoUrl: vTeamData.team.logo ? vTeamData.team.logo : '',
playoffStatus: playoffStatus,
baseballSituation: baseballSituation,
+ footballSituation: footballSituation,
})
}
})
@@ -1114,6 +1146,37 @@ module.exports = {
return formattedGamesList
},
+ /*
+ Compute gridiron coordinates from ESPN's possessionText ("DAL 22", "50").
+ x scale: 0 = visitor goal line (left), 100 = home goal line (right).
+ Abbreviations are trimmed because formatScores may append a trailing
+ space to disambiguate colliding NCAA abbreviations (e.g. 'SDSU ').
+ */
+ computeFieldPosition: function (possessionText, distance, possession, hAbbr, vAbbr) {
+ var result = { ballX: null, firstDownX: null }
+ var text = (possessionText == null ? '' : String(possessionText)).trim()
+ if (text === '50') {
+ result.ballX = 50
+ }
+ else {
+ var m = text.match(/^(.*\S)\s+(\d{1,2})$/)
+ if (!m) return result
+ var abbr = m[1].trim()
+ var n = parseInt(m[2], 10)
+ if (n < 1 || n > 50) return result
+ if (abbr === (vAbbr || '').trim()) result.ballX = n
+ else if (abbr === (hAbbr || '').trim()) result.ballX = 100 - n
+ else return result
+ }
+ if (typeof distance === 'number' && distance > 0
+ && (possession === 'home' || possession === 'away')) {
+ var fd = possession === 'home' ? result.ballX - distance : result.ballX + distance
+ // Goal-to-go lands in the end zone: the goal line itself is the marker
+ if (fd > 0 && fd < 100) result.firstDownX = fd
+ }
+ return result
+ },
+
formatT25Ranking: function (rank) {
if (rank >= 1 && rank <= 25) {
return rank
@@ -1310,6 +1373,7 @@ module.exports = {
vTeamLogoUrl: getLogo(vData.team),
playoffStatus: '',
baseballSituation: null,
+ footballSituation: null,
gameDate: comp.date,
}
},
diff --git a/tests/e2e/mm-bootstrap/config-builder.js b/tests/e2e/mm-bootstrap/config-builder.js
index a792e25..93a117d 100644
--- a/tests/e2e/mm-bootstrap/config-builder.js
+++ b/tests/e2e/mm-bootstrap/config-builder.js
@@ -13,7 +13,7 @@ module.exports = {
ipWhitelist: [],
language: 'en',
locale: 'en-US',
- logLevel: ['INFO', 'LOG', 'WARN', 'ERROR'],
+ logLevel: ['INFO', 'LOG', 'WARN', 'ERROR', 'DEBUG'],
timeFormat: 12,
units: 'imperial',
modules: [
diff --git a/tests/e2e/mm-bootstrap/install-mm.mjs b/tests/e2e/mm-bootstrap/install-mm.mjs
index 5876735..f0343d4 100644
--- a/tests/e2e/mm-bootstrap/install-mm.mjs
+++ b/tests/e2e/mm-bootstrap/install-mm.mjs
@@ -32,7 +32,7 @@ function copyModuleSources() {
// tests/e2e/.tmp (recursion!), .git, package-lock.json.
fs.mkdirSync(MODULE_TARGET, { recursive: true })
const SKIP = new Set([
- 'node_modules', '.git', '.github', 'tests', 'logos_custom',
+ 'node_modules', '.git', '.github', 'tests',
])
function walk(srcDir, dstDir) {
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
diff --git a/tests/e2e/mm-bootstrap/start-mm.js b/tests/e2e/mm-bootstrap/start-mm.js
index c5608b9..ad844b1 100644
--- a/tests/e2e/mm-bootstrap/start-mm.js
+++ b/tests/e2e/mm-bootstrap/start-mm.js
@@ -77,4 +77,24 @@ async function startMM(opts) {
throw new Error(`MagicMirror did not respond within ${timeoutMs}ms.\nstdout tail:\n${stdoutBuf.slice(-2000)}`)
}
-module.exports = { startMM }
+/*
+ Wait until nothing is answering on the port. stop() resolves when the npm
+ wrapper exits, but the MM server process can linger for a moment — if the
+ next scenario's readiness probe hits the dying instance it sees the OLD
+ config and the test runs against the wrong fixture data.
+*/
+async function waitForPortFree(port, timeoutMs = 10000) {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(300) })
+ }
+ catch {
+ return // connection refused/timeout — port is free
+ }
+ await new Promise(r => setTimeout(r, 200))
+ }
+ throw new Error(`Port ${port} still occupied ${timeoutMs}ms after MM stop()`)
+}
+
+module.exports = { startMM, waitForPortFree }
diff --git a/tests/e2e/scenarios/_index.js b/tests/e2e/scenarios/_index.js
index 27bd386..65bc234 100644
--- a/tests/e2e/scenarios/_index.js
+++ b/tests/e2e/scenarios/_index.js
@@ -3,9 +3,9 @@
/*
Scenario registry. The MATRIX_MODE env var selects which scenarios run:
- MATRIX_MODE=curated (default) — 25 hand-picked scenarios covering every
+ MATRIX_MODE=curated (default) — 30 hand-picked scenarios covering every
view style, key flag, PR #198 upcoming
- games, broadcasts, and baseball detail.
+ games, broadcasts, and baseball/football detail.
MATRIX_MODE=pairwise — algorithmic covering array (~15–25 scenarios).
Every pair of parameter values appears at least
once. Reuses the same NHL fixture.
@@ -54,9 +54,14 @@ const CURATED = [
require('./broadcast-multiple-networks.js'),
require('./broadcast-showLocalBroadcasts-on.js'),
- // G. Baseball detail (2)
+ // G. Baseball/football detail (7)
require('./baseball-detail-on.js'),
require('./baseball-detail-bases-loaded.js'),
+ require('./football-detail-on.js'),
+ require('./football-detail-red-zone.js'),
+ require('./football-detail-halftime.js'),
+ require('./football-detail-touchdown.js'),
+ require('./football-detail-mono.js'),
// H. Multi-sport (1)
require('./multi-nfl-nhl-mlb.js'),
diff --git a/tests/e2e/scenarios/football-detail-halftime.js b/tests/e2e/scenarios/football-detail-halftime.js
new file mode 100644
index 0000000..ca35ad9
--- /dev/null
+++ b/tests/e2e/scenarios/football-detail-halftime.js
@@ -0,0 +1,43 @@
+'use strict'
+
+const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')
+
+module.exports = {
+ name: 'football-detail-halftime',
+ moduleConfig: {
+ viewStyle: 'largeLogos',
+ showLeagueSeparators: true,
+ colored: true,
+ highlightWinners: true,
+ showDetail: true,
+ detailViewOverride: true,
+ sports: [{ league: 'NFL', teams: ['DAL'] }],
+ },
+ fixtures: {
+ [espnScoreboardRoute('football/nfl')]: {
+ events: [{
+ id: 'nfl-live-halftime',
+ date: todayMidLocalISO(),
+ status: { type: { id: '23', detail: 'Halftime', shortDetail: 'Halftime', description: 'Halftime' } },
+ competitions: [{
+ date: todayMidLocalISO(),
+ status: { type: { id: '23', detail: 'Halftime', shortDetail: 'Halftime', description: 'Halftime' } },
+ broadcasts: [],
+ situation: {
+ homeTimeouts: 3,
+ awayTimeouts: 3,
+ },
+ competitors: [
+ { homeAway: 'home', score: '10', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
+ { homeAway: 'away', score: '13', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
+ ],
+ }],
+ }],
+ },
+ },
+ assertions: [async (page, expect) => {
+ await expect(page.locator('.MMM-MyScoreboard .football-field')).toHaveCount(0)
+ await expect(page.locator('.MMM-MyScoreboard .box-score.football-has-field')).toHaveCount(0)
+ await expect(page.locator('.MMM-MyScoreboard .football-timeouts')).toHaveCount(2)
+ }],
+}
diff --git a/tests/e2e/scenarios/football-detail-mono.js b/tests/e2e/scenarios/football-detail-mono.js
new file mode 100644
index 0000000..f2c316f
--- /dev/null
+++ b/tests/e2e/scenarios/football-detail-mono.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')
+
+module.exports = {
+ name: 'football-detail-mono',
+ moduleConfig: {
+ viewStyle: 'largeLogos',
+ showLeagueSeparators: true,
+ colored: false,
+ highlightWinners: true,
+ showDetail: true,
+ detailViewOverride: true,
+ sports: [{ league: 'NFL', teams: ['DAL'] }],
+ },
+ fixtures: {
+ [espnScoreboardRoute('football/nfl')]: {
+ events: [{
+ id: 'nfl-live-mono',
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ competitions: [{
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ broadcasts: [],
+ situation: {
+ down: 2,
+ distance: 7,
+ downDistanceText: '2nd & 7 at DAL 22',
+ shortDownDistanceText: '2nd & 7',
+ possessionText: 'DAL 22',
+ possession: '6',
+ isRedZone: false,
+ homeTimeouts: 3,
+ awayTimeouts: 2,
+ lastPlay: { id: '1', text: 'D. Prescott pass short right to C. Lamb for 9 yards' },
+ },
+ competitors: [
+ { homeAway: 'home', score: '14', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
+ { homeAway: 'away', score: '10', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
+ ],
+ }],
+ }],
+ },
+ },
+ assertions: [async (page, expect) => {
+ const ball = page.locator('.MMM-MyScoreboard .field-ball')
+ await expect(ball).toHaveCount(1)
+ // Full mono: gray ball, gray turf, gray first-down line — no color leak
+ expect(await ball.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(204, 204, 204)')
+ expect(await page.locator('.MMM-MyScoreboard .football-field').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(36, 36, 36)')
+ expect(await page.locator('.MMM-MyScoreboard .field-first-down').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(204, 204, 204)')
+ }],
+}
diff --git a/tests/e2e/scenarios/football-detail-on.js b/tests/e2e/scenarios/football-detail-on.js
new file mode 100644
index 0000000..d2f258f
--- /dev/null
+++ b/tests/e2e/scenarios/football-detail-on.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')
+
+module.exports = {
+ name: 'football-detail-on',
+ moduleConfig: {
+ viewStyle: 'largeLogos',
+ showLeagueSeparators: true,
+ colored: true,
+ highlightWinners: true,
+ showDetail: true,
+ detailViewOverride: true,
+ sports: [{ league: 'NFL', teams: ['DAL'] }],
+ },
+ fixtures: {
+ [espnScoreboardRoute('football/nfl')]: {
+ events: [{
+ id: 'nfl-live-detail',
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ competitions: [{
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ broadcasts: [],
+ situation: {
+ down: 2,
+ distance: 7,
+ yardLine: 78,
+ downDistanceText: '2nd & 7 at DAL 22',
+ shortDownDistanceText: '2nd & 7',
+ possessionText: 'DAL 22',
+ possession: '6',
+ isRedZone: false,
+ homeTimeouts: 3,
+ awayTimeouts: 2,
+ lastPlay: { id: '1', text: 'D. Prescott pass short right to C. Lamb for 9 yards (Q. Diggs)' },
+ },
+ competitors: [
+ { homeAway: 'home', score: '14', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
+ { homeAway: 'away', score: '10', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
+ ],
+ }],
+ }],
+ },
+ },
+ assertions: [async (page, expect) => {
+ const ball = page.locator('.MMM-MyScoreboard .field-ball')
+ await expect(ball).toHaveCount(1)
+ expect(await ball.evaluate(el => el.style.getPropertyValue('--ball-x'))).toBe('78%')
+ await expect(page.locator('.MMM-MyScoreboard .field-first-down')).toHaveCount(1)
+ await expect(page.locator('.MMM-MyScoreboard .football-last-play')).toHaveCount(0)
+ }],
+}
diff --git a/tests/e2e/scenarios/football-detail-red-zone.js b/tests/e2e/scenarios/football-detail-red-zone.js
new file mode 100644
index 0000000..4ecfac5
--- /dev/null
+++ b/tests/e2e/scenarios/football-detail-red-zone.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')
+
+module.exports = {
+ name: 'football-detail-red-zone',
+ moduleConfig: {
+ viewStyle: 'largeLogos',
+ showLeagueSeparators: true,
+ colored: true,
+ highlightWinners: true,
+ showDetail: true,
+ detailViewOverride: true,
+ sports: [{ league: 'NFL', teams: ['DAL'] }],
+ },
+ fixtures: {
+ [espnScoreboardRoute('football/nfl')]: {
+ events: [{
+ id: 'nfl-live-red-zone',
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '4th 2:00', description: 'In Progress' } },
+ competitions: [{
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '4th 2:00', description: 'In Progress' } },
+ broadcasts: [],
+ situation: {
+ down: 1,
+ distance: 4,
+ yardLine: 96,
+ downDistanceText: '1st & Goal at DAL 4',
+ shortDownDistanceText: '1st & Goal',
+ possessionText: 'DAL 4',
+ possession: '21',
+ isRedZone: true,
+ homeTimeouts: 1,
+ awayTimeouts: 0,
+ lastPlay: { id: '2', text: 'J. Hurts scramble' },
+ },
+ competitors: [
+ { homeAway: 'home', score: '20', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
+ { homeAway: 'away', score: '17', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
+ ],
+ }],
+ }],
+ },
+ },
+ assertions: [async (page, expect) => {
+ const ball = page.locator('.MMM-MyScoreboard .field-ball')
+ await expect(ball).toHaveCount(1)
+ expect(await ball.evaluate(el => el.style.getPropertyValue('--ball-x'))).toBe('96%')
+ // Goal-to-go: first-down marker hidden (goal line is the marker)
+ await expect(page.locator('.MMM-MyScoreboard .field-first-down')).toHaveCount(0)
+ }],
+}
diff --git a/tests/e2e/scenarios/football-detail-touchdown.js b/tests/e2e/scenarios/football-detail-touchdown.js
new file mode 100644
index 0000000..6dc71da
--- /dev/null
+++ b/tests/e2e/scenarios/football-detail-touchdown.js
@@ -0,0 +1,48 @@
+'use strict'
+
+const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')
+
+module.exports = {
+ name: 'football-detail-touchdown',
+ moduleConfig: {
+ viewStyle: 'largeLogos',
+ showLeagueSeparators: true,
+ colored: true,
+ highlightWinners: true,
+ showDetail: true,
+ detailViewOverride: true,
+ sports: [{ league: 'NFL', teams: ['DAL'] }],
+ },
+ fixtures: {
+ [espnScoreboardRoute('football/nfl')]: {
+ events: [{
+ id: 'nfl-live-touchdown',
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '3rd 5:12', description: 'In Progress' } },
+ competitions: [{
+ date: todayMidLocalISO(),
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '3rd 5:12', description: 'In Progress' } },
+ broadcasts: [],
+ situation: {
+ possession: '21',
+ homeTimeouts: 2,
+ awayTimeouts: 3,
+ lastPlay: { id: '3', text: 'S. Barkley 20 yard rush, TOUCHDOWN' },
+ },
+ competitors: [
+ { homeAway: 'home', score: '14', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
+ { homeAway: 'away', score: '16', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
+ ],
+ }],
+ }],
+ },
+ },
+ assertions: [async (page, expect) => {
+ // Field stays visible on a touchdown; ball parked in the scored end zone
+ await expect(page.locator('.MMM-MyScoreboard .football-field')).toHaveCount(1)
+ await expect(page.locator('.MMM-MyScoreboard .field-endzone.home .field-ball.in-endzone')).toHaveCount(1)
+ await expect(page.locator('.MMM-MyScoreboard .field-play-area .field-ball')).toHaveCount(0)
+ await expect(page.locator('.MMM-MyScoreboard .field-first-down')).toHaveCount(0)
+ await expect(page.locator('.MMM-MyScoreboard .football-play-text')).toContainText('TOUCHDOWN')
+ }],
+}
diff --git a/tests/e2e/specs/matrix.spec.js b/tests/e2e/specs/matrix.spec.js
index c62e83c..e03bf0d 100644
--- a/tests/e2e/specs/matrix.spec.js
+++ b/tests/e2e/specs/matrix.spec.js
@@ -7,7 +7,7 @@ const path = require('node:path')
const { SCENARIOS } = require('../scenarios/_index')
const { buildConfig } = require('../mm-bootstrap/config-builder')
const { startFixtureServer } = require('../fixtures/http-server')
-const { startMM } = require('../mm-bootstrap/start-mm.js')
+const { startMM, waitForPortFree } = require('../mm-bootstrap/start-mm.js')
const REPO_ROOT = path.resolve(__dirname, '../../..')
const MM_DIR = path.resolve(REPO_ROOT, 'tests/e2e/.tmp/MagicMirror')
@@ -28,13 +28,29 @@ test.afterAll(async () => {
if (fixtureSrv) await fixtureSrv.close().catch(() => {})
})
+let scenarioIdx = 0
for (const scenario of SCENARIOS) {
+ // Unique port per scenario: a page from a finished test can outlive its
+ // teardown for a moment, and its socket.io client auto-reconnects to
+ // whatever is listening on the old port. With a shared port that zombie
+ // (carrying stale noGamesToday/ydLoaded latches) reaches the NEXT
+ // scenario's MM, and the helper's replies — addressed by an instanceId
+ // that is identical on every page — wipe the live page's data.
+ const scenarioPort = MM_PORT + (scenarioIdx++ % 100)
test(scenario.name, async ({ page }) => {
// Wire fixtures for this scenario.
fixtureSrv.setRoutes(scenario.fixtures ?? {})
// Write a config.js for MagicMirror.
- const cfg = buildConfig({ ...scenario, port: MM_PORT })
+ // Before 3 AM local the module's rolloverHours logic requests
+ // yesterday's date, but fixtures are keyed to today — shift the module
+ // clock past the rollover window so the suite passes at any hour.
+ const rolloverShim = new Date().getHours() < 3 ? { debugHours: 4 } : {}
+ const cfg = buildConfig({
+ ...scenario,
+ moduleConfig: { ...rolloverShim, ...scenario.moduleConfig },
+ port: scenarioPort,
+ })
fs.mkdirSync(path.dirname(MM_CONFIG), { recursive: true })
fs.writeFileSync(MM_CONFIG, cfg)
@@ -43,18 +59,40 @@ for (const scenario of SCENARIOS) {
await mmProc.stop().catch(() => {})
mmProc = null
}
+ await waitForPortFree(scenarioPort)
mmProc = await startMM({
mmDir: MM_DIR,
fixturePort: fixtureSrv.port,
- port: MM_PORT,
+ port: scenarioPort,
})
- await page.goto(`http://localhost:${MM_PORT}/`)
- // Wait for either rendered scoreboard or dimmed loading state.
- await page.waitForSelector(
- '.MMM-MyScoreboard .box-score, .MMM-MyScoreboard .dimmed, .MMM-MyScoreboard',
- { timeout: 15_000 },
+ await page.goto(`http://localhost:${scenarioPort}/`)
+ // Wait until MagicMirror's core has actually initialized AND the module
+ // rendered content (box scores) or its dimmed loading state. A bare
+ // region div is not enough — pages occasionally come up with the MM
+ // core half-dead (MM global missing, module never starts); reload once
+ // to recover instead of asserting against a blank page.
+ const moduleReady = () => page.waitForFunction(
+ /* eslint-disable no-undef */
+ () => {
+ // MM is a lexically-scoped global (const), not window.MM
+ if (typeof MM === 'undefined') return false
+ if (document.querySelector('.MMM-MyScoreboard .box-score, .MMM-MyScoreboard .dimmed')) return true
+ // No-games scenarios render neither — accept once data has arrived
+ const m = MM.getModules().withClass('MMM-MyScoreboard')[0]
+ return !!(m && m.loaded)
+ },
+ /* eslint-enable no-undef */
+ { timeout: 8_000 },
)
+ try {
+ await moduleReady()
+ }
+ catch {
+ console.log(`[matrix] ${scenario.name}: MM not ready after goto — reloading once`)
+ await page.reload()
+ await moduleReady()
+ }
// Let CSS animations settle.
await page.waitForTimeout(800)
@@ -64,7 +102,36 @@ for (const scenario of SCENARIOS) {
})
for (const a of (scenario.assertions ?? [])) {
- await a(page, expect)
+ try {
+ await a(page, expect)
+ }
+ catch (e) {
+ // Dump module state to the test output for flake diagnosis
+ const state = await page.evaluate(() => {
+ /* eslint-disable no-undef */
+ const region = document.querySelector('.MMM-MyScoreboard')
+ const base = {
+ url: location.href,
+ readyState: document.readyState,
+ hasMMGlobal: typeof MM !== 'undefined',
+ regionPresent: !!region,
+ regionHtmlLen: region ? region.innerHTML.length : -1,
+ bodyLen: document.body ? document.body.innerHTML.length : -1,
+ scripts: Array.from(document.querySelectorAll('script')).length,
+ }
+ const m = typeof MM !== 'undefined' && MM.getModules().withClass('MMM-MyScoreboard')[0]
+ if (!m) return { ...base, module: 'not found' }
+ return {
+ ...base,
+ loaded: m.loaded,
+ handshakePending: m.localLogoRetryTimer !== null,
+ sportsDataKeys: Object.keys(m.sportsData || {}),
+ sportsData: JSON.stringify(m.sportsData || {}).slice(0, 300),
+ }
+ }).catch(err => `evaluate failed: ${err}`)
+ console.log(`[matrix] ${scenario.name} module state on failure:`, JSON.stringify(state))
+ throw e
+ }
}
})
}
diff --git a/tests/unit/providers/ESPN.schedule.test.js b/tests/unit/providers/ESPN.schedule.test.js
index 14ffa63..82d7090 100644
--- a/tests/unit/providers/ESPN.schedule.test.js
+++ b/tests/unit/providers/ESPN.schedule.test.js
@@ -159,6 +159,7 @@ describe('ESPN.extractNextGame', () => {
assert.equal(next.hTeamLogoUrl, 'https://espn.com/tor.png')
assert.equal(next.broadcast.length, 0)
assert.equal(next.baseballSituation, null)
+ assert.equal(next.footballSituation, null)
assert.equal(next.gameDate, future)
})
diff --git a/tests/unit/providers/ESPN.scoreboard.test.js b/tests/unit/providers/ESPN.scoreboard.test.js
index 8c16475..973f40e 100644
--- a/tests/unit/providers/ESPN.scoreboard.test.js
+++ b/tests/unit/providers/ESPN.scoreboard.test.js
@@ -145,3 +145,133 @@ describe('ESPN module shape', () => {
}
})
})
+
+describe('ESPN.formatScores footballSituation', () => {
+ const moment = require('moment-timezone')
+
+ function liveNflEvent(situation) {
+ const nowISO = new Date().toISOString()
+ return {
+ events: [{
+ id: 'nfl-live',
+ date: nowISO,
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ competitions: [{
+ date: nowISO,
+ status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
+ broadcasts: [],
+ situation: situation,
+ competitors: [
+ { homeAway: 'home', score: '14', id: '6', team: { abbreviation: 'DAL', name: 'Cowboys', id: '6' } },
+ { homeAway: 'away', score: '10', id: '21', team: { abbreviation: 'PHI', name: 'Eagles', id: '21' } },
+ ],
+ }],
+ }],
+ }
+ }
+
+ function format(situation) {
+ const payload = { league: 'NFL', teams: ['DAL'], whichDay: { today: true } }
+ const games = ESPN.formatScores(payload, liveNflEvent(situation), moment().format('YYYYMMDD'))
+ assert.equal(games.length, 1)
+ return games[0].footballSituation
+ }
+
+ it('maps home possession by competitor id', () => {
+ const fb = format({ shortDownDistanceText: '2nd & 7', possessionText: 'DAL 22', distance: 7, possession: '6', homeTimeouts: 3, awayTimeouts: 2, lastPlay: { text: 'run for 3 yards' } })
+ assert.equal(fb.possession, 'home')
+ assert.equal(fb.downDistance, '2nd & 7')
+ assert.equal(fb.possessionText, 'DAL 22')
+ assert.equal(fb.lastPlay, 'run for 3 yards')
+ assert.equal(fb.ballX, 78)
+ assert.equal(fb.firstDownX, 71)
+ assert.equal(fb.homeTimeouts, 3)
+ assert.equal(fb.awayTimeouts, 2)
+ })
+
+ it('maps away possession by competitor id', () => {
+ const fb = format({ possession: '21' })
+ assert.equal(fb.possession, 'away')
+ })
+
+ it('unknown possession id degrades to null', () => {
+ const fb = format({ possession: '9999' })
+ assert.equal(fb.possession, null)
+ })
+
+ it('halftime-style partial situation null-guards every field', () => {
+ const fb = format({ homeTimeouts: 3, awayTimeouts: 3 })
+ assert.equal(fb.downDistance, '')
+ assert.equal(fb.possessionText, '')
+ assert.equal(fb.ballX, null)
+ assert.equal(fb.firstDownX, null)
+ assert.equal(fb.possession, null)
+ assert.equal(fb.isRedZone, false)
+ assert.equal(fb.homeTimeouts, 3)
+ })
+
+ it('no situation object yields null footballSituation', () => {
+ assert.equal(format(undefined), null)
+ })
+
+ it('falls back to full downDistanceText when short form missing', () => {
+ const fb = format({ downDistanceText: '2nd & 7 at DAL 22' })
+ assert.equal(fb.downDistance, '2nd & 7 at DAL 22')
+ })
+})
+
+describe('ESPN.computeFieldPosition', () => {
+ const cfp = (text, distance, possession) => ESPN.computeFieldPosition(text, distance, possession, 'DAL', 'PHI')
+
+ it('visitor own side maps directly', () => {
+ assert.equal(cfp('PHI 22').ballX, 22)
+ })
+
+ it('home own side mirrors to 100 - n', () => {
+ assert.equal(cfp('DAL 22').ballX, 78)
+ })
+
+ it('midfield "50" maps to 50', () => {
+ assert.equal(cfp('50').ballX, 50)
+ })
+
+ it('first down toward visitor end zone for home possession', () => {
+ const r = cfp('DAL 22', 7, 'home')
+ assert.equal(r.ballX, 78)
+ assert.equal(r.firstDownX, 71)
+ })
+
+ it('first down toward home end zone for away possession', () => {
+ const r = cfp('PHI 40', 10, 'away')
+ assert.equal(r.ballX, 40)
+ assert.equal(r.firstDownX, 50)
+ })
+
+ it('goal-to-go hides the first-down marker', () => {
+ const r = cfp('PHI 4', 4, 'home')
+ assert.equal(r.ballX, 4)
+ assert.equal(r.firstDownX, null)
+ })
+
+ it('unknown abbreviation yields null ballX', () => {
+ assert.equal(cfp('XYZ 22').ballX, null)
+ })
+
+ it('unparseable text yields null ballX', () => {
+ assert.equal(cfp('MIDFIELD').ballX, null)
+ assert.equal(cfp('').ballX, null)
+ assert.equal(cfp(null).ballX, null)
+ })
+
+ it('missing distance yields null firstDownX', () => {
+ const r = cfp('DAL 22', undefined, 'home')
+ assert.equal(r.ballX, 78)
+ assert.equal(r.firstDownX, null)
+ })
+
+ it('trims trailing-space abbreviations (SDSU collision fix)', () => {
+ const r = ESPN.computeFieldPosition('SDSU 22', 5, 'home', 'SDSU ', 'MTST')
+ assert.equal(r.ballX, 78)
+ assert.equal(r.firstDownX, 73)
+ })
+})