From 1fb8bc51c345416a0819634581e9254e9ca65c05 Mon Sep 17 00:00:00 2001 From: Christian Gerber Date: Wed, 2 Sep 2026 00:29:12 -0400 Subject: [PATCH 1/5] Add live football detail with animated gridiron (NFL, NCAAF) In-progress football games can show the live situation, mirroring showBaseballDetail: down & distance and field position, a gridiron graphic with the ball spot and first-down line, the last play text, a possession indicator under the possessing team's logo, timeouts under each score, and red-zone accents. On each new play the ball slides to the new spot with a green/red gain-loss trail; the first-down marker holds the old line-to-gain until the ball has crossed it. Touchdowns keep the field visible with the ball in the scored end zone through the PAT. Ball position is derived from ESPN's possessionText in the provider (computeFieldPosition, unit tested), so the frontend never sees team ids. Config: showFootballDetail, footballDetailInterval, footballDetailViewOverride. The baseball fast-poll was generalized to a data-driven per-sport mechanism (fixes the hardcoded league list drift at the old line 1016); getBaseballScoresOnly is now getFastPollScoresOnly(leagues). Tests: 16 new unit tests; 4 new e2e scenarios (live, red zone, halftime degradation, touchdown) with numeric assertions. README documents the new options with screenshots, plus the previously undocumented baseballDetailViewOverride. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S1UJvj9naLkkQgAGqNLVrw --- MMM-MyScoreboard.css | 495 ++++++++++++++++++ MMM-MyScoreboard.js | 248 ++++++++- README.md | 22 + footballDetailExample1.png | Bin 0 -> 14405 bytes footballDetailExample2.png | Bin 0 -> 15708 bytes footballDetailExample3.png | Bin 0 -> 12965 bytes footballDetailExample4.png | Bin 0 -> 12364 bytes providers/ESPN.js | 64 +++ tests/e2e/scenarios/_index.js | 10 +- .../e2e/scenarios/football-detail-halftime.js | 43 ++ tests/e2e/scenarios/football-detail-on.js | 54 ++ .../e2e/scenarios/football-detail-red-zone.js | 54 ++ .../scenarios/football-detail-touchdown.js | 48 ++ tests/unit/providers/ESPN.schedule.test.js | 1 + tests/unit/providers/ESPN.scoreboard.test.js | 130 +++++ 15 files changed, 1142 insertions(+), 27 deletions(-) create mode 100644 footballDetailExample1.png create mode 100644 footballDetailExample2.png create mode 100644 footballDetailExample3.png create mode 100644 footballDetailExample4.png create mode 100644 tests/e2e/scenarios/football-detail-halftime.js create mode 100644 tests/e2e/scenarios/football-detail-on.js create mode 100644 tests/e2e/scenarios/football-detail-red-zone.js create mode 100644 tests/e2e/scenarios/football-detail-touchdown.js diff --git a/MMM-MyScoreboard.css b/MMM-MyScoreboard.css index 5e6cab1..dd12c6e 100644 --- a/MMM-MyScoreboard.css +++ b/MMM-MyScoreboard.css @@ -1230,6 +1230,501 @@ } +/* 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 */ +.MMM-MyScoreboard .box-score.red-zone .football-field-position, +.MMM-MyScoreboard .wrapper.colored .box-score.red-zone .football-field-position { + color: #ff5a5a; + font-weight: bold; +} + +.MMM-MyScoreboard .box-score.red-zone .possession-indicator, +.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: #1e3324; + border: 1px solid #333; + border-radius: 3px; +} +.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: #ffe14d; + opacity: 0.9; +} + +/* 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(90, 220, 110, 0.55); +} +.MMM-MyScoreboard .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, +.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..325795e 100644 --- a/MMM-MyScoreboard.js +++ b/MMM-MyScoreboard.js @@ -37,6 +37,9 @@ Module.register('MMM-MyScoreboard', { showBaseballDetail: false, baseballDetailInterval: 15, baseballDetailViewOverride: true, + showFootballDetail: false, + footballDetailInterval: 15, + footballDetailViewOverride: true, showScoreAnimation: false, showUpcomingGames: false, sports: [ @@ -319,9 +322,15 @@ 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: {}, + fastPollSports: [ + { key: 'baseball', leagues: 'baseballLeagues', show: 'showBaseballDetail', interval: 'baseballDetailInterval' }, + { key: 'football', leagues: 'footballLeagues', show: 'showFootballDetail', interval: 'footballDetailInterval' }, + ], viewStyleHasLogos: function (v) { switch (v) { @@ -437,6 +446,12 @@ Module.register('MMM-MyScoreboard', { viewStyle = 'largeLogos' } + // Override viewStyle for active football games with detail enabled + if (this.config.showFootballDetail && this.config.footballDetailViewOverride + && gameObj.footballSituation && this.footballLeagues.includes(league)) { + viewStyle = 'largeLogos' + } + var boxScore = document.createElement('div') boxScore.classList.add('box-score', league.replaceAll(' ', '')) boxScore.classList.add(viewStyle) @@ -720,6 +735,189 @@ 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.showFootballDetail && 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 sits in the end zone the scoring team reached + if (tdSide) { + var tdBall = document.createElement('div') + tdBall.classList.add('field-ball', 'in-endzone') + ;(tdSide === 'visitor' ? ezV : ezH).appendChild(tdBall) + } + + 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 +1207,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') @@ -1459,14 +1659,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..b8559b3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil | `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`) +| `baseballDetailViewOverride` | When `showBaseballDetail` is enabled, forces the `largeLogos` view for in-progress baseball games so the detail 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` +| `showFootballDetail` | When set to `true`, in-progress football games (NFL, NCAAF) display the current game situation: 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 is shown in the end zone until the ensuing kickoff.

**Type** `Boolean`
**Default** `false` +| `footballDetailInterval` | When `showFootballDetail` is enabled and a football game is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only football leagues are polled at this faster rate.

**Type** `Number`
**Default** `15` (seconds, minimum `1`) +| `footballDetailViewOverride` | When `showFootballDetail` is enabled, forces the `largeLogos` view for in-progress football games so the detail 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 +109,24 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil ![Baseball Detail](example5.png) +#### Football Detail Examples + +Live play — gridiron with ball spot, first-down line, timeouts, and last play: + +![Football Detail](footballDetailExample1.png) + +Ball sliding to the new spot with a gain trail: + +![Football Detail Gain Trail](footballDetailExample2.png) + +Red zone — field position, possession indicator, and ball turn red: + +![Football Detail Red Zone](footballDetailExample3.png) + +Touchdown — the ball sits in the end zone until the kickoff: + +![Football Detail Touchdown](footballDetailExample4.png) + #### Score Animation Example ![Score Animation](scoreAnimationsExample.gif) diff --git a/footballDetailExample1.png b/footballDetailExample1.png new file mode 100644 index 0000000000000000000000000000000000000000..3399b824c82d181be84defc31ac3f8b97df414ce GIT binary patch literal 14405 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 T`L>APH6RiqvclDZ`houkvhrE6 literal 0 HcmV?d00001 diff --git a/footballDetailExample2.png b/footballDetailExample2.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa38fe7a70248b3e11aebeecf48cfd3519616bb GIT binary patch literal 15708 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 CAbuJE literal 0 HcmV?d00001 diff --git a/footballDetailExample3.png b/footballDetailExample3.png new file mode 100644 index 0000000000000000000000000000000000000000..c681065861e6e13f2263fba4b54a84d175c1bca0 GIT binary patch literal 12965 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 Ydto)!J^dYs0sE-zLiY7qGU0W;0YQ2+n{ literal 0 HcmV?d00001 diff --git a/footballDetailExample4.png b/footballDetailExample4.png new file mode 100644 index 0000000000000000000000000000000000000000..fb9a6db80e867b2fd57fe44f2db1d609a530de2e GIT binary patch literal 12364 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 Uz^d^KZh?@MQj)CyW)%E?0Of#nUH||9 literal 0 HcmV?d00001 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/scenarios/_index.js b/tests/e2e/scenarios/_index.js index 27bd386..c1ded77 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) — 29 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,13 @@ const CURATED = [ require('./broadcast-multiple-networks.js'), require('./broadcast-showLocalBroadcasts-on.js'), - // G. Baseball detail (2) + // G. Baseball/football detail (6) 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'), // 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..ec11722 --- /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, + showFootballDetail: true, + footballDetailViewOverride: 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-on.js b/tests/e2e/scenarios/football-detail-on.js new file mode 100644 index 0000000..dc9b7dc --- /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, + showFootballDetail: true, + footballDetailViewOverride: 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..5c6bab7 --- /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, + showFootballDetail: true, + footballDetailViewOverride: 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..2a65ec5 --- /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, + showFootballDetail: true, + footballDetailViewOverride: 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/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) + }) +}) From af14492cffabde13d33674a12ae58e502b7bbb47 Mon Sep 17 00:00:00 2001 From: Christian Gerber Date: Wed, 2 Sep 2026 01:57:17 -0400 Subject: [PATCH 2/5] Harden e2e harness and startup robustness against cross-scenario bleed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suite was flaky (~1 random blank-page failure per run, any scenario): a page from a finished test can outlive its teardown, and its socket.io client auto-reconnects to the next scenario's MagicMirror on the shared port. Its stale noGamesToday/ydLoaded latches produce whichDay {today:false} requests whose instant notRun replies — addressed by an instanceId identical on every page — wipe the live page's freshly loaded scores. Screenshot-only scenarios passed silently on blank pages; the football-detail assertions exposed it. Harness fixes: - Unique MM port per scenario (matrix.spec.js) so a zombie page can never reach the next scenario's server; plus waitForPortFree after stop() (start-mm.js) and a real readiness check (MM core global + rendered content, one reload retry) instead of a bare-div selector. - debugHours shim before 3 AM local so the module's rolloverHours logic requests the fixture-keyed date at any wall-clock time. - install-mm now copies logos_custom; DEBUG logLevel in the test config; module state dumped to test output on assertion failure. Module robustness (real-world wins from the same investigation): - node_helper no longer throws at startup when logos/logos_custom is missing (getDirectoryTree existence guard) and Log.debugs each GET-SCORES request and response size. - The frontend retries the startup GET-LOCAL-LOGOS handshake until answered — previously a reply lost during a slow start meant score polling never began. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S1UJvj9naLkkQgAGqNLVrw --- MMM-MyScoreboard.js | 17 ++++- node_helper.js | 5 ++ tests/e2e/mm-bootstrap/config-builder.js | 2 +- tests/e2e/mm-bootstrap/install-mm.mjs | 2 +- tests/e2e/mm-bootstrap/start-mm.js | 22 +++++- tests/e2e/specs/matrix.spec.js | 85 +++++++++++++++++++++--- 6 files changed, 120 insertions(+), 13 deletions(-) diff --git a/MMM-MyScoreboard.js b/MMM-MyScoreboard.js index 325795e..0c84eaa 100644 --- a/MMM-MyScoreboard.js +++ b/MMM-MyScoreboard.js @@ -327,6 +327,7 @@ Module.register('MMM-MyScoreboard', { fastPollTimers: {}, fastPollActive: {}, footballFieldState: {}, + localLogoRetryTimer: null, fastPollSports: [ { key: 'baseball', leagues: 'baseballLeagues', show: 'showBaseballDetail', interval: 'baseballDetailInterval' }, { key: 'football', leagues: 'footballLeagues', show: 'showFootballDetail', interval: 'footballDetailInterval' }, @@ -1293,6 +1294,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 @@ -1552,10 +1561,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() 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/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/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 + } } }) } From 46102b43d4e7f4de5e8d783b7c92d135177dd0a4 Mon Sep 17 00:00:00 2001 From: Christian Gerber Date: Wed, 2 Sep 2026 09:27:10 -0400 Subject: [PATCH 3/5] Full mono football detail when colored is off; animate touchdown slide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-colored mode previously leaked color into the gridiron: green turf, yellow first-down line, green/red trails, red red-zone accents. All field colors now live behind .wrapper.colored, matching the baseball detail convention — mono defaults are dark-gray turf, #CCC first-down line, white/gray gain-loss trails, and white red-zone emphasis (ball, possession indicator, field position). Touchdowns now animate: when the scoring play arrives with a known previous spot, the ball slides from that spot into the end zone with a gain trail (end-zone targets sit past the 0-100% play scale, so the slide keyframes carry it across the goal line). The PAT and later updates render the parked end-zone ball statically, so the slide plays once per score. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S1UJvj9naLkkQgAGqNLVrw --- MMM-MyScoreboard.css | 34 ++++++++++++++++++++++++++-------- MMM-MyScoreboard.js | 28 ++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/MMM-MyScoreboard.css b/MMM-MyScoreboard.css index dd12c6e..e198e5a 100644 --- a/MMM-MyScoreboard.css +++ b/MMM-MyScoreboard.css @@ -1377,14 +1377,18 @@ color: #f8dd70; } -/* Red zone accent */ -.MMM-MyScoreboard .box-score.red-zone .football-field-position, +/* 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; - font-weight: bold; } -.MMM-MyScoreboard .box-score.red-zone .possession-indicator, +.MMM-MyScoreboard .box-score.red-zone .possession-indicator { + background-color: #FFF; +} .MMM-MyScoreboard .wrapper.colored .box-score.red-zone .possession-indicator { background-color: #ff5a5a; } @@ -1433,10 +1437,13 @@ box-sizing: border-box; display: flex; overflow: hidden; - background-color: #1e3324; + 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; } @@ -1513,9 +1520,12 @@ bottom: 0; width: 2px; margin-left: -1px; - background-color: #ffe14d; + 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 */ @@ -1590,9 +1600,15 @@ animation: msb-fb-trail-fade 2s ease-out forwards; } .MMM-MyScoreboard .field-trail.gain { - background-color: rgba(90, 220, 110, 0.55); + 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 { @@ -1610,7 +1626,9 @@ .MMM-MyScoreboard .wrapper.colored .field-ball { background-color: #f8dd70; } -.MMM-MyScoreboard .box-score.red-zone .field-ball, +.MMM-MyScoreboard .box-score.red-zone .field-ball { + background-color: #FFF; +} .MMM-MyScoreboard .wrapper.colored .box-score.red-zone .field-ball { background-color: #ff5a5a; } diff --git a/MMM-MyScoreboard.js b/MMM-MyScoreboard.js index 0c84eaa..22fb52a 100644 --- a/MMM-MyScoreboard.js +++ b/MMM-MyScoreboard.js @@ -856,11 +856,31 @@ Module.register('MMM-MyScoreboard', { ezH.classList.add('field-endzone', 'home') field.appendChild(ezH) - // Touchdown: ball sits in the end zone the scoring team reached + // 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) { - var tdBall = document.createElement('div') - tdBall.classList.add('field-ball', 'in-endzone') - ;(tdSide === 'visitor' ? ezV : ezH).appendChild(tdBall) + 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) From 9b92b1722b3b52c52ea39c47dd7db43d55b03e72 Mon Sep 17 00:00:00 2001 From: Christian Gerber Date: Wed, 2 Sep 2026 09:30:54 -0400 Subject: [PATCH 4/5] Add monochrome football detail screenshot and e2e coverage New football-detail-mono e2e scenario locks in the colored:false grayscale rendering (gray ball, turf, first-down line) and provides the README screenshot. README shows the mono example and notes the touchdown slide. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S1UJvj9naLkkQgAGqNLVrw --- README.md | 6 ++- footballDetailExample5.png | Bin 0 -> 12167 bytes tests/e2e/scenarios/_index.js | 5 +- tests/e2e/scenarios/football-detail-mono.js | 54 ++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 footballDetailExample5.png create mode 100644 tests/e2e/scenarios/football-detail-mono.js diff --git a/README.md b/README.md index b8559b3..291ac51 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,14 @@ Red zone — field position, possession indicator, and ball turn red: ![Football Detail Red Zone](footballDetailExample3.png) -Touchdown — the ball sits in the end zone until the kickoff: +Touchdown — the ball slides into the end zone and sits there until the kickoff: ![Football Detail Touchdown](footballDetailExample4.png) +With `colored: false`, the field goes full grayscale like the rest of the display: + +![Football Detail Monochrome](footballDetailExample5.png) + #### Score Animation Example ![Score Animation](scoreAnimationsExample.gif) diff --git a/footballDetailExample5.png b/footballDetailExample5.png new file mode 100644 index 0000000000000000000000000000000000000000..8a84f986a0af02d3902ea181cb2953b8cf23c5bb GIT binary patch literal 12167 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 K5|v^mLH`HFH;ASH literal 0 HcmV?d00001 diff --git a/tests/e2e/scenarios/_index.js b/tests/e2e/scenarios/_index.js index c1ded77..65bc234 100644 --- a/tests/e2e/scenarios/_index.js +++ b/tests/e2e/scenarios/_index.js @@ -3,7 +3,7 @@ /* Scenario registry. The MATRIX_MODE env var selects which scenarios run: - MATRIX_MODE=curated (default) — 29 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/football detail. MATRIX_MODE=pairwise — algorithmic covering array (~15–25 scenarios). @@ -54,13 +54,14 @@ const CURATED = [ require('./broadcast-multiple-networks.js'), require('./broadcast-showLocalBroadcasts-on.js'), - // G. Baseball/football detail (6) + // 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-mono.js b/tests/e2e/scenarios/football-detail-mono.js new file mode 100644 index 0000000..c72a9f9 --- /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, + showFootballDetail: true, + footballDetailViewOverride: 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)') + }], +} From d909703c128c98af8aeb58d4a3b6cd3e5ee5d593 Mon Sep 17 00:00:00 2001 From: Christian Gerber Date: Wed, 2 Sep 2026 09:36:55 -0400 Subject: [PATCH 5/5] Unify detail options: showDetail / detailInterval / detailViewOverride MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One set of options now controls live-game detail for every supported sport (baseball and football) instead of per-sport flags. The baseball-specific options (showBaseballDetail, baseballDetailInterval, baseballDetailViewOverride) are deprecated but still work — start() maps them onto the unified options so existing configs are unaffected — and they are no longer documented in the README. The football options introduced on this branch were never released and are removed outright. The baseball e2e scenarios intentionally keep the deprecated options in their configs, so the suite exercises the compatibility mapping. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S1UJvj9naLkkQgAGqNLVrw --- MMM-MyScoreboard.js | 40 +++++++++++++------ README.md | 11 ++--- .../e2e/scenarios/football-detail-halftime.js | 4 +- tests/e2e/scenarios/football-detail-mono.js | 4 +- tests/e2e/scenarios/football-detail-on.js | 4 +- .../e2e/scenarios/football-detail-red-zone.js | 4 +- .../scenarios/football-detail-touchdown.js | 4 +- 7 files changed, 42 insertions(+), 29 deletions(-) diff --git a/MMM-MyScoreboard.js b/MMM-MyScoreboard.js index 22fb52a..f362e77 100644 --- a/MMM-MyScoreboard.js +++ b/MMM-MyScoreboard.js @@ -34,12 +34,13 @@ Module.register('MMM-MyScoreboard', { debugHours: 0, debugMinutes: 0, showPlayoffStatus: false, - showBaseballDetail: false, - baseballDetailInterval: 15, - baseballDetailViewOverride: true, - showFootballDetail: false, - footballDetailInterval: 15, - footballDetailViewOverride: true, + showDetail: false, + detailInterval: 15, + detailViewOverride: true, + /* + Deprecated aliases (mapped onto the unified options in start()): + showBaseballDetail, baseballDetailInterval, baseballDetailViewOverride + */ showScoreAnimation: false, showUpcomingGames: false, sports: [ @@ -329,8 +330,8 @@ Module.register('MMM-MyScoreboard', { footballFieldState: {}, localLogoRetryTimer: null, fastPollSports: [ - { key: 'baseball', leagues: 'baseballLeagues', show: 'showBaseballDetail', interval: 'baseballDetailInterval' }, - { key: 'football', leagues: 'footballLeagues', show: 'showFootballDetail', interval: 'footballDetailInterval' }, + { key: 'baseball', leagues: 'baseballLeagues', show: 'showDetail', interval: 'detailInterval' }, + { key: 'football', leagues: 'footballLeagues', show: 'showDetail', interval: 'detailInterval' }, ], viewStyleHasLogos: function (v) { @@ -442,13 +443,13 @@ 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.showFootballDetail && this.config.footballDetailViewOverride + if (this.config.showDetail && this.config.detailViewOverride && gameObj.footballSituation && this.footballLeagues.includes(league)) { viewStyle = 'largeLogos' } @@ -653,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 @@ -742,7 +743,7 @@ Module.register('MMM-MyScoreboard', { && (gameObj.gameMode === this.gameModes.FINAL || !gameObj.footballSituation)) { delete this.footballFieldState[fbGameKey] } - if (this.config.showFootballDetail && gameObj.footballSituation) { + 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 @@ -1528,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 */ diff --git a/README.md b/README.md index 291ac51..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,12 +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`) -| `baseballDetailViewOverride` | When `showBaseballDetail` is enabled, forces the `largeLogos` view for in-progress baseball games so the detail 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` -| `showFootballDetail` | When set to `true`, in-progress football games (NFL, NCAAF) display the current game situation: 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 is shown in the end zone until the ensuing kickoff.

**Type** `Boolean`
**Default** `false` -| `footballDetailInterval` | When `showFootballDetail` is enabled and a football game is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only football leagues are polled at this faster rate.

**Type** `Number`
**Default** `15` (seconds, minimum `1`) -| `footballDetailViewOverride` | When `showFootballDetail` is enabled, forces the `largeLogos` view for in-progress football games so the detail 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` +| `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` diff --git a/tests/e2e/scenarios/football-detail-halftime.js b/tests/e2e/scenarios/football-detail-halftime.js index ec11722..ca35ad9 100644 --- a/tests/e2e/scenarios/football-detail-halftime.js +++ b/tests/e2e/scenarios/football-detail-halftime.js @@ -9,8 +9,8 @@ module.exports = { showLeagueSeparators: true, colored: true, highlightWinners: true, - showFootballDetail: true, - footballDetailViewOverride: true, + showDetail: true, + detailViewOverride: true, sports: [{ league: 'NFL', teams: ['DAL'] }], }, fixtures: { diff --git a/tests/e2e/scenarios/football-detail-mono.js b/tests/e2e/scenarios/football-detail-mono.js index c72a9f9..f2c316f 100644 --- a/tests/e2e/scenarios/football-detail-mono.js +++ b/tests/e2e/scenarios/football-detail-mono.js @@ -9,8 +9,8 @@ module.exports = { showLeagueSeparators: true, colored: false, highlightWinners: true, - showFootballDetail: true, - footballDetailViewOverride: true, + showDetail: true, + detailViewOverride: true, sports: [{ league: 'NFL', teams: ['DAL'] }], }, fixtures: { diff --git a/tests/e2e/scenarios/football-detail-on.js b/tests/e2e/scenarios/football-detail-on.js index dc9b7dc..d2f258f 100644 --- a/tests/e2e/scenarios/football-detail-on.js +++ b/tests/e2e/scenarios/football-detail-on.js @@ -9,8 +9,8 @@ module.exports = { showLeagueSeparators: true, colored: true, highlightWinners: true, - showFootballDetail: true, - footballDetailViewOverride: true, + showDetail: true, + detailViewOverride: true, sports: [{ league: 'NFL', teams: ['DAL'] }], }, fixtures: { diff --git a/tests/e2e/scenarios/football-detail-red-zone.js b/tests/e2e/scenarios/football-detail-red-zone.js index 5c6bab7..4ecfac5 100644 --- a/tests/e2e/scenarios/football-detail-red-zone.js +++ b/tests/e2e/scenarios/football-detail-red-zone.js @@ -9,8 +9,8 @@ module.exports = { showLeagueSeparators: true, colored: true, highlightWinners: true, - showFootballDetail: true, - footballDetailViewOverride: true, + showDetail: true, + detailViewOverride: true, sports: [{ league: 'NFL', teams: ['DAL'] }], }, fixtures: { diff --git a/tests/e2e/scenarios/football-detail-touchdown.js b/tests/e2e/scenarios/football-detail-touchdown.js index 2a65ec5..6dc71da 100644 --- a/tests/e2e/scenarios/football-detail-touchdown.js +++ b/tests/e2e/scenarios/football-detail-touchdown.js @@ -9,8 +9,8 @@ module.exports = { showLeagueSeparators: true, colored: true, highlightWinners: true, - showFootballDetail: true, - footballDetailViewOverride: true, + showDetail: true, + detailViewOverride: true, sports: [{ league: 'NFL', teams: ['DAL'] }], }, fixtures: {