Compare commits

...

115 Commits

Author SHA1 Message Date
josh 7784eaf9ce feat: overhaul hype score algorithm with 9 hockey-driven improvements
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 16s
- Empty net detection: pulled goalie triggers +150-250 bonus, stacks with PP
- Playoff series importance: Game 7 Cup Final = 200, elimination games scale
  by round; fallback of 100 when series data unavailable
- Period-aware score differential: 2-goal deficit penalty DECREASES in final
  2 min of P3 (goalie-pull zone), 3+ goal games get harsher penalties late
- Persistent comeback narrative: tracks max deficit, sustained bonus for 2+
  goal recoveries instead of one-shot spike (0-3 to 3-3 = 150 persistent)
- Shootout special handling: flat base 550 with no time component; ranks below
  dramatic close P3 games (skills competition, not hockey)
- Multi-man advantage: parses situationCode for 5v3/4v3, applies 1.6x PP mult
- Non-linear time priority: elapsed^1.5 curve weights final minutes more
- Matchup multiplier rebalance: P1/P2 from 2.0/1.65 to 1.5, tiebreaker not
  dominant factor
- Frontend gauge max raised from 700 to 1000 with adjusted color thresholds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:02:43 -04:00
josh 6c098850f5 fix: use truthy check for intermission filter, add route test
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 19s
`is True` strict identity fails if the NHL API returns an integer 1
instead of a JSON boolean. A truthy check is safe here since the
Intermission field is always False/0 for non-intermission live games.

Also adds a test that verifies intermission games are separated from
live games in the /scoreboard response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:57:00 -04:00
josh f652743333 feat: add intermission section separate from live games
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 18s
Games in intermission now appear in their own section between Live and
Scheduled. The section is hidden when no games are in intermission,
matching the behavior of the other section headings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:50:47 -04:00
josh 429c42e7b0 fix: scale badges and hype label at desktop breakpoints
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 21s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:43:27 -04:00
josh 8e1c455ded fix: correct NHL API situation structure for power play detection
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 22s
The NHL API nests situationDescriptions under situation.homeTeam /
situation.awayTeam, not at the top level. The old flat-structure
lookup always returned an empty list, silently breaking both the
PP indicator on the frontend and the PP bonus in the hype score.
Updated get_power_play_info, the _priority_components PP check,
and all test fixtures to match the real API shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:37:31 -04:00
josh 9edc9914a3 fix: invert matchup scoring so top-ranked teams boost priority
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 19s
Previously league_sequence was summed raw and subtracted, meaning
rank-1 teams received the smallest penalty — directionally correct
but fragile and confusing. Now the rank is inverted (33 - sequence)
so rank 1 contributes 32 quality points and the result is added as
a bonus, making the intent explicit: better matchups = higher hype.
Also renames the breakdown field matchup → matchup_bonus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:34:05 -04:00
josh cf0dec3513 fix: scale section headings with desktop breakpoints
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:26:30 -04:00
josh 58c31d6766 feat: responsive desktop scaling for game cards
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 18s
Adds two desktop breakpoints (900px, 1400px) that progressively
increase card width (290→340→400px), logo size (40→48→56px), score
font size (1.6→1.9→2.2rem), and team name size. Adds max-width on
main to keep layout centred on ultra-wide screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:23:47 -04:00
josh c9f5c7c929 feat: expose hype score breakdown in /scoreboard response
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 19s
Adds a "Hype Breakdown" dict to every game in the API response with
the individual components that sum to Priority: base period score,
time priority, matchup penalty, closeness bonus, power play bonus,
comeback bonus, and importance sub-components (season weight, playoff
relevance, rivalry multiplier). Achieved by extracting private
_priority_components() and _importance_components() helpers; public
function signatures and all tests unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:13:05 -04:00
josh 3d77c7cd5a feat: PWA support with hockey puck icon
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 17s
Adds full PWA compliance: web app manifest, service worker with
cache-first static / network-first scoreboard strategy, and a
generated hockey puck icon (512, 192, 180, 32px) on the app's
dark navy background. Includes all required meta tags for iOS
standalone mode and a /favicon.ico route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:04:33 -04:00
josh 2f2b3f2d7e docs: update README with hype scoring and game importance details
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:44:29 -04:00
josh bf39bb6bd5 style: apply ruff formatting
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:41:10 -04:00
josh 47a8c34215 feat: game importance factor in hype scoring
CI / Lint (push) Failing after 6s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
Adds calculate_game_importance() that boosts Priority for high-stakes
regular-season matchups based on season progress (sharp ramp after game
55), playoff bubble proximity (wildcard rank ~17-19 = max relevance),
and divisional/conference rivalry (1.4x/1.2x multipliers). Max bonus
150 pts applied to both LIVE and PRE games; playoff and FINAL games
are unaffected. Extends standings schema with division, conference,
games_played, and wildcard_sequence fields fetched from the NHL API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:39:55 -04:00
josh 8945b99782 feat: power play indicator with live countdown clock
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
Shows a red pill below the team rows when a PP is active, displaying
the team on the power play and a ticking countdown. PP clock always
resyncs from the API (no local anchoring) since 2-minute penalties
are short enough that accuracy matters throughout. Removed the old
inline PP text from team rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:58:31 -04:00
josh 257e2151c8 fix: smooth intermission clock by preserving local anchor across renders
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 16s
Snapshot the locally-computed clock state before each re-render and
restore it afterwards, so the API response doesn't cause a visible
jump. Only resync to the API value in the final 60 seconds, where
accuracy matters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:55:48 -04:00
josh 96529c4705 feat: smooth clock countdown for intermission and live play
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 17s
Store seconds + received-at timestamp on time badge. A 1s interval
decrements locally so the clock never stutters between API polls.
Drift-corrected: always computed from the anchored API value, not
accumulated ticks. Re-render on each API response reanchors to the
real value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:51:05 -04:00
josh e2d2c7dd97 feat: overhaul hype scoring algorithm
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s
- Period base: playoff OT escalates indefinitely (P4=600, P5=750…),
  reg season P4=5-min OT (600), P5=shootout (700)
- Time priority range increased (max ~300 vs old ~120), calibrated
  to period length so 5-min reg season OT reads correctly
- Matchup multiplier inverted: higher period = less weight (any OT
  is exciting regardless of teams)
- Replace unconditional score_total with closeness bonus: rewards
  tight games regardless of goal volume (5-4 == 1-0 at same diff)
- Power play bonus: 30 (P1/P2) → 50/100/150 (P3 by time) → 200 (OT)
- Comeback bonus: one-time pulse (+50/75/100 by period) when trailing
  team scores to within 2 goals; keyed on team names, clears after
  firing, skips intermission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:46:10 -04:00
josh 62afc1001e feat: amber top border for intermission games
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:10:02 -04:00
josh 3169d1a1ff fix: resolve 4 logic bugs found in code review
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 17s
- utc_to_eastern: use zoneinfo instead of hardcoded EDT offset (-4)
  so start times are correct in both EST and EDT
- standings: fetch before truncate so a failed API call doesn't wipe
  existing standings data
- routes: call parse_games() once per request instead of three times
- scheduler: wrap run_pending() in try/except so an unhandled exception
  doesn't kill the background thread

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:06:45 -04:00
josh 56feb0a5f2 polish: improve game card aesthetics
CI / Lint (push) Successful in 19s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 18s
- larger scores (1.6rem), logos (40px), and card width (290px)
- green top border accent on live game cards
- section headings reduced to small muted caps
- more breathing room in team rows
- slightly larger card radius and gap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:00:18 -04:00
josh ed05d6adfc refactor: replace shots bar with inline SOG on team rows
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:57:00 -04:00
josh 889f429dc6 feat: move shots bar between team rows for cleaner layout
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:53:40 -04:00
josh 1394b21fb3 fix: use monochromatic near-white and slate gray for shots bar
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:49:52 -04:00
josh cb712245c2 fix: show shots bar during intermission
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 14s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:46:42 -04:00
josh 869a7a91b7 fix: use cyan and lime for shots bar
CI / Lint (push) Successful in 9s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:59 -04:00
josh 66fff68e6a fix: use sky blue and pink for shots bar to improve contrast
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:42:41 -04:00
josh bada8c0b7b fix: use perceptually balanced colors for shots bar
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:40:59 -04:00
josh 9ad563ed3f feat: add shots on goal bar to live game cards, clean up gitignore
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:19:59 -04:00
josh def491a4d4 test: add full test suite with 100% coverage across all modules
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 7s
CI / Build & Push (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:17:20 -04:00
josh dd5ac945bd refactor: rename functions across codebase for clarity
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
2026-03-29 10:21:01 -04:00
josh a4dc7dff52 refactor: flatten app/scoreboard/ subpackage and rename files for clarity
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 16s
2026-03-29 10:16:35 -04:00
josh da277e41a4 fix: center game boxes on page
CI / Lint (push) Successful in 4s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
2026-03-29 09:48:47 -04:00
josh d1d711828c fix: center and resize page title and section headings
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 16s
2026-03-29 09:45:18 -04:00
josh 10d7cb9b02 refactor: rewrite UI with clean layout, fetch API, and proper card structure
CI / Lint (push) Successful in 4s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
2026-03-29 09:41:34 -04:00
josh 73af434851 fix: run as root to allow volume mount writes
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 15s
2026-03-29 09:35:56 -04:00
josh f911d5d59d fix: pull image from Gitea registry instead of building locally
CI / Lint (push) Successful in 4s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Successful in 14s
2026-03-29 09:34:00 -04:00
josh 1d2901035e fix: add owner namespace to registry image path
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 16s
2026-03-29 09:32:06 -04:00
josh 72ad9568cd fix: rename reserved GITEA_ prefixed secrets to REGISTRY/REGISTRY_TOKEN
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 5s
CI / Build & Push (push) Failing after 21s
2026-03-29 09:28:01 -04:00
josh 3994943757 good luck
CI / Lint (push) Successful in 58s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Failing after 1m33s
2026-03-29 09:20:21 -04:00
goddard b10736d43c merge develop into main for v4.1.9 (#44) 2024-03-11 12:38:28 -04:00
goddard 8913b40a8c hotfix: correct for dst 2024-03-11 12:37:19 -04:00
goddard daabae1e49 fix: add shootout indicator 2024-02-25 01:17:04 -05:00
goddard 53a0fc7993 merge develop into main for v4.1.8 (#43) 2024-02-22 23:05:25 -05:00
goddard a1352869ad fix: adjust game score calculation 2024-02-22 23:04:49 -05:00
goddard f059d4228b fix: raise gauge ceiling to 700 2024-02-22 23:04:38 -05:00
goddard c8f535ee48 fix: record sizes are now consistent (#42) 2024-02-22 02:16:39 -05:00
goddard 65369896cc fix: record sizes are now consistent 2024-02-22 02:16:12 -05:00
goddard 7e41cf4781 fix: correct date crossover time (#41) 2024-02-22 02:09:28 -05:00
goddard 20ffd05df1 fix: correct date crossover time 2024-02-22 02:08:56 -05:00
goddard 2e85ced6ce fix: drop leading zero for scheduled games (#40) 2024-02-22 02:06:34 -05:00
goddard 5d65533ff5 fix: drop leading zero for scheduled games 2024-02-22 02:06:15 -05:00
goddard 085514ab16 fix: change date crossover to 3:00 am ETC (#39) 2024-02-22 02:01:01 -05:00
goddard 960ff6e5ac fix: change date crossover to 3:00 am ETC 2024-02-22 02:00:39 -05:00
goddard 04e29469dd fix: adjust scoreboard time (#38) 2024-02-22 01:57:10 -05:00
goddard 360188114e fix: adjust scoreboard time 2024-02-22 01:56:46 -05:00
goddard 982fdfb3c1 merge develop into main for v4.1.2 (#37) 2024-02-22 01:17:52 -05:00
goddard 94f9cced2e game priority: double differential adjustment at 5 minutes left in third 2024-02-22 01:17:13 -05:00
goddard 3edb84c333 fix: change scale to 650 instead of 600 2024-02-22 01:16:31 -05:00
goddard 6ec9a7aef1 fix: lower weight of total score 2024-02-22 00:14:39 -05:00
goddard dfb86f6fd5 changes hype meter scale to 600 instead of 700 2024-02-22 00:14:14 -05:00
goddard e5824cefc5 fix: sort games in intermission by time left 2024-02-22 00:11:22 -05:00
goddard 18ff48cc2c feat: add matchup adjustment scaling. Earlier the period, the heavier… (#36) 2024-02-21 23:52:44 -05:00
goddard 8c5de8602f feat: add matchup adjustment scaling. Earlier the period, the heavier we way the matchup strength 2024-02-21 23:52:06 -05:00
goddard 9f4a6c966a merge develop into main for v4.0.1 (#35) 2024-02-21 23:43:29 -05:00
goddard 4da3c2dfdd fix: live game adjustments 2024-02-21 23:42:41 -05:00
goddard 07ff5ac055 feat: Push the games that are in intermission to the bottom, but retain their sort 2024-02-21 18:40:21 -05:00
goddard fe7449537b fix: minor header adjustments 2024-02-21 18:34:26 -05:00
goddard dd8d1ca12b fix: adjust pre and final state 2024-02-21 01:30:00 -05:00
goddard d285314a28 hotfix: define font size for more consistent look 2024-02-21 01:00:03 -05:00
goddard 1cd6c7314b Merge develop into main for v4.0.0 (#34) 2024-02-21 00:43:41 -05:00
goddard 93b53b86b9 feature: adds hype meter to replace game score (#33) 2024-02-21 00:41:18 -05:00
goddard 53f0e69cc5 feature: adds hype meter to replace game score 2024-02-21 00:40:35 -05:00
goddard 4b6e8615b1 fix: weigh overtime heavier to compensate for time priority loss 2024-02-20 22:10:07 -05:00
goddard 31b4846287 fix: styles.css adjust live games 2024-02-20 22:09:45 -05:00
goddard a329c2e8b2 fix: styles.css adjustments 2024-02-20 14:32:17 -05:00
goddard 24de212b98 remove unnecessary comments 2024-02-20 01:35:24 -05:00
goddard 6abcd2e448 fix: change spacing between rows 2024-02-20 01:27:19 -05:00
goddard b3a09b27c0 fix: adjust game box spacing 2024-02-20 00:50:35 -05:00
goddard 8900bf0d14 fix: adjust sizing to fit space better 2024-02-20 00:47:29 -05:00
goddard 3d6afe0df3 fix: adjust styles 2024-02-20 00:41:49 -05:00
goddard d4f5e4c991 fix: make elements more consistent across screen sizes 2024-02-19 21:57:02 -05:00
goddard 59fe338658 fix: drop leading zero oin start time 2024-02-19 21:56:06 -05:00
goddard 28265af13f fix: resize game boxes and space out (#32) 2024-02-19 19:11:58 -05:00
goddard 1fb7673aa4 fix: resize game boxes and space out 2024-02-19 19:10:56 -05:00
goddard 5bac025624 Merge develop into main for v3.0.7 (#31) 2024-02-19 16:33:48 -05:00
goddard 0d3921e7cf fix: game priority update (#30) 2024-02-19 16:31:49 -05:00
goddard f17e221ad3 fix: game priority update 2024-02-19 16:31:15 -05:00
goddard 1c2028fa85 fix: shootout is period 5 and gets top priority (#29) 2024-02-19 16:24:13 -05:00
goddard 9fe94057ff fix: shootout is period 5 and gets top priority 2024-02-19 16:23:34 -05:00
goddard e355693613 fix: live game state and time spacing and size (#28) 2024-02-19 16:21:45 -05:00
goddard 468a03e646 fix: live game state and time spacing and size 2024-02-19 16:20:21 -05:00
goddard d89d674b2a hotfix: process_data.py fix incorrect database ref (#25) 2024-02-19 13:13:51 -05:00
goddard 7b5bde447a hotfix: process_data.py fix incorrect database ref 2024-02-19 13:13:29 -05:00
goddard 975ac4d4ec fix: fixes checks for non live games (#24) 2024-02-19 03:50:07 -05:00
goddard 8611471360 fix: fixes checks for non live games 2024-02-19 03:49:02 -05:00
goddard 18802f6ef5 hotfix: update dockerfile 2024-02-19 02:32:20 -05:00
goddard 4304954bc3 hotfix: change dockerfile 2024-02-19 02:27:31 -05:00
goddard 90cccf581a Merge develop into main for v3.0.2 (#23) 2024-02-19 02:14:18 -05:00
goddard a3ee38d774 fix: last period type (#22) 2024-02-19 02:07:40 -05:00
goddard 5beb7e2b44 fix: last period type 2024-02-19 02:07:08 -05:00
goddard c926821e1a .gitignore: update gitignore 2024-02-19 01:31:39 -05:00
goddard a21bb3cdcc Merge pull request #20 from JoshNotWright/fix/create-data-folder
fix: creates data folder
2024-02-19 01:30:03 -05:00
goddard 148bdaefc4 fix: creates data folder 2024-02-19 01:29:34 -05:00
goddard e645cb2b08 Update README.md 2024-02-19 01:20:56 -05:00
goddard 55b5b166d4 Merge pull request #18 from JoshNotWright/hotfix/change-dockerfile
Hotfix: update dockerfile
2024-02-19 01:16:32 -05:00
goddard 2ad85d5b51 Hotfix: update dockerfile 2024-02-19 01:16:15 -05:00
goddard bc3e6eb9ea Merge pull request #17 from JoshNotWright/develop
Refactor: Change entire project structure
2024-02-19 01:08:20 -05:00
goddard 7f36d3f767 Merge pull request #16 from JoshNotWright/refactor/absolutely-everything
refactor: changes entire project structure
2024-02-19 01:06:22 -05:00
goddard aae9ba4a27 refactor: changes entire project structure 2024-02-19 01:05:33 -05:00
goddard 3e45c22b59 .gitignore: Update gitignore 2024-02-18 22:45:20 -05:00
goddard d07330891f Merge pull request #15 from JoshNotWright/feature/update-standings
Feature: Update NHL standings automatically
2024-02-18 22:42:50 -05:00
goddard 0412b07f35 app.py: add update_nhl_standings function and set to run every 5 min 2024-02-18 22:40:51 -05:00
goddard 2543036ddd update .gitignore: add nhl standings and remove from repo 2024-02-18 22:40:01 -05:00
goddard c0f1be346c Merge pull request #14 from JoshNotWright/feature/better-refresh
Update styles.css: fix sections
2024-02-18 18:24:11 -05:00
goddard 1b376b4aa1 styles.css: fix sections 2024-02-18 18:23:27 -05:00
38 changed files with 3151 additions and 813 deletions
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install ruff
run: pip install ruff==0.8.6
- name: Check formatting
run: ruff format --check .
- name: Check linting
run: ruff check .
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -v
build-push:
name: Build & Push
runs-on: ubuntu-latest
needs: test
# Only build on pushes to main or version tags — not on PRs
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.REGISTRY }}/${{ gitea.repository_owner }}/nhlscoreboard
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-23
View File
@@ -1,23 +0,0 @@
name: Docker Build and Push
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- uses: mr-smithers-excellent/docker-build-push@v6.3
name: Build & push Docker image
with:
image: joshnotwright/nhlscoreboard
tags: ${{ github.event.release.tag_name }}, latest
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
+8 -1
View File
@@ -1,2 +1,9 @@
/nhle_scoreboard_response.txt
/nhle_standings_response.txt
/nhle_standings_response.txt
/app/data/nhl_standings.db
/app/data/scoreboard_data.json
nhl_standings.db
**/__pycache__
.venv/
.coverage
.pytest_cache/
+12 -16
View File
@@ -1,25 +1,21 @@
# Use an official Python runtime as a parent image
FROM python:3.9-slim
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/app/app/data
# Set the working directory in the container
WORKDIR /app
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed dependencies specified in requirements.txt
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Expose the Flask port
COPY . .
RUN mkdir -p $DATA_DIR
EXPOSE 2897
# Copy static files and templates
COPY ./templates /app/templates
COPY ./static /app/static
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:2897/')" || exit 1
# Run the Flask application
CMD ["python", "app.py"]
CMD ["python", "run.py"]
+31 -19
View File
@@ -1,19 +1,38 @@
# NHL Scoreboard Web App
This web application displays live NHL game scores, team statistics, and game states in real-time. It retrieves data from the NHL API and presents it in a user-friendly interface.
A web application that displays live NHL game scores, team statistics, and game states in real-time. Retrieves data from the NHL API and surfaces the most interesting games first using a hype scoring algorithm.
## Features
- Displays live NHL game scores.
- Sorts games based on priority to highlight the most exciting matchups.
- Responsive design for desktop and mobile devices.
- Live NHL game scores, shots on goal, and game state
- Power play indicators with live countdown clock
- Smooth period and intermission countdown clocks
- **Hype Meter** — ranks games by how exciting they are right now, factoring in:
- Period, time remaining, and score differential
- Late-game urgency and comeback bonuses
- Power play situations
- **Game importance** — boosts standings-relevant matchups late in the season, with extra weight for division and conference rivals fighting for playoff spots
## Hype Scoring
Games are sorted by a priority score combining in-game excitement and contextual importance:
| Factor | Description |
|---|---|
| Period & time | Later period and deeper into it = higher base score |
| Score differential | Close games rank higher; blowouts are penalized |
| Late urgency | Bonus for ties and 1-goal games in the final 12 minutes |
| Comeback | One-time spike when the trailing team scores to pull within 2 |
| Power play | Bonus for an active man advantage, scaling with game situation |
| Game importance | Season-progress × playoff-bubble proximity × rivalry multiplier (max +150 pts) |
Game importance ramps up sharply after ~game 55 of 82, peaks for teams fighting for the last wildcard spot, and applies a 1.4× bonus for division games and 1.2× for conference games.
## Technologies Used
- **Python**: Backend scripting language.
- **Flask**: Web framework for Python.
- **HTML/CSS**: Frontend markup and styling.
- **JavaScript**: Client-side scripting for auto-refresh functionality.
- **Python / Flask** — backend and API polling
- **SQLite** — standings cache (refreshed every 10 minutes)
- **HTML / CSS / JavaScript** — frontend with auto-refresh every 10 seconds
## Installation
@@ -32,22 +51,15 @@ This web application displays live NHL game scores, team statistics, and game st
3. Run the application:
```bash
python app.py
python run.py
```
4. Open your web browser and navigate to `http://localhost:2897` to view the scoreboard.
## Usage
- The scoreboard will display live NHL game scores, team statistics, and game states.
- Games are sorted based on priority to highlight the most exciting matchups.
- The page updates automatically every 10 seconds to show the latest data.
- Responsive design ensures a seamless experience on desktop and mobile devices.
4. Open `http://localhost:2897` in your browser.
## Credits
Special thanks to the NHL for providing the data through their API.
Data provided by the NHL via their public API.
## License
This project is licensed under the [MIT License](LICENSE).
[MIT License](LICENSE)
-254
View File
@@ -1,254 +0,0 @@
from flask import Flask, render_template, jsonify
import requests
from datetime import datetime, timedelta
from waitress import serve
import sqlite3
import threading
import time
import schedule
import json
app = Flask(__name__)
# Configuration
scoreboard_data = None
# Data Retrieval
def get_nhle_scoreboard():
now = datetime.now()
start_time_evening = now.replace(hour=23, minute=0, second=0, microsecond=0) # 7:00 PM EST
end_time_evening = now.replace(hour=8, minute=0, second=0, microsecond=0) # 3:00 AM EST
if now >= start_time_evening or now < end_time_evening:
# Use now URL
nhle_api_url = "https://api-web.nhle.com/v1/score/now"
else:
# Use current data URL
nhle_api_url = f"https://api-web.nhle.com/v1/score/{now.strftime('%Y-%m-%d')}"
response = requests.get(nhle_api_url)
if response.status_code == 200:
return response.json()
else:
print("Error:", response.status_code)
# Store scoreboard data locally
def store_scoreboard_data():
global scoreboard_data
scoreboard_data = get_nhle_scoreboard()
# Schedule the task to run every 10 seconds
def schedule_task():
schedule.every(10).seconds.do(store_scoreboard_data)
while True:
schedule.run_pending()
time.sleep(1)
# Data Processing
def extract_game_info():
global scoreboard_data
if scoreboard_data:
extracted_info = []
for game in scoreboard_data.get("games", []):
home_team = game["homeTeam"]["name"]["default"]
away_team = game["awayTeam"]["name"]["default"]
home_logo = game["homeTeam"]["logo"]
away_logo = game["awayTeam"]["logo"]
game_state = convert_game_state(game["gameState"])
period, time_remaining, time_running, is_intermission = process_time_and_period(game_state, game)
home_score, away_score, home_shots, away_shots = process_scores_and_shots(game_state, game)
start_time_str, home_record, away_record = process_start_time_and_records(game_state, game)
game_priority = calculate_game_priority(game)
# Get power play information
home_power_play = get_power_play_info(game, home_team)
away_power_play = get_power_play_info(game, away_team)
# Get game outcome
last_period_type = get_game_outcome(game_state, game)
extracted_info.append({
"Home Team": home_team,
"Home Score": home_score,
"Away Team": away_team,
"Away Score": away_score,
"Home Logo": home_logo,
"Away Logo": away_logo,
"Game State": game_state,
"Period": period,
"Time Remaining": time_remaining,
"Time Running": time_running,
"Intermission": is_intermission,
"Priority": game_priority,
"Start Time": start_time_str,
"Home Record": home_record,
"Away Record": away_record,
"Home Shots": home_shots,
"Away Shots": away_shots,
"Home Power Play": home_power_play,
"Away Power Play": away_power_play,
"Last Period Type": last_period_type
})
# Sort games based on priority
sorted_info = sorted(extracted_info, key=lambda x: x["Priority"], reverse=True)
return sorted_info
def convert_game_state(game_state):
if game_state == "OFF":
return "FINAL"
elif game_state == "CRIT":
return "LIVE"
elif game_state == "FUT":
return "PRE"
else:
return game_state
def process_time_and_period(game_state, game):
if game_state in ["PRE", "FUT"]:
return 0, "20:00", False, False
elif game_state in ["FINAL", "OFF"]:
return "N/A", "00:00", False, False
else:
period = game["periodDescriptor"]["number"]
time_remaining = game["clock"]["timeRemaining"]
if time_remaining == "00:00":
time_remaining = "END"
time_running = game["clock"]["running"]
is_intermission = game["clock"]["inIntermission"]
return period, time_remaining, time_running, is_intermission
def process_scores_and_shots(game_state, game):
if game_state in ["PRE", "FUT"]:
return 0, 0, 0, 0
else:
return game["homeTeam"]["score"], game["awayTeam"]["score"], game["homeTeam"]["sog"], game["awayTeam"]["sog"]
def process_start_time_and_records(game_state, game):
if game_state in ["PRE", "FUT"]:
start_time_utc = game["startTimeUTC"]
start_time_str = utc_to_est_time(start_time_utc)
home_record = game["homeTeam"]["record"]
away_record = game["awayTeam"]["record"]
game_state = "PRE"
else:
start_time_str = "N/A"
home_record = "N/A"
away_record = "N/A"
return start_time_str, home_record, away_record
def get_power_play_info(game, team_name):
if "situation" in game and "situationDescriptions" in game["situation"]:
for situation in game["situation"]["situationDescriptions"]:
if situation == "PP" and game["awayTeam"]["name"]["default"] == team_name:
return f"PP {game['situation']['timeRemaining']}"
elif situation == "PP" and game["homeTeam"]["name"]["default"] == team_name:
return f"PP {game['situation']['timeRemaining']}"
return "" # Return empty string if team is not on power play
def get_game_outcome(game_state, game):
if game_state == "FINAL":
last_period_type = game["gameOutcome"]["lastPeriodType"]
else:
last_period_type = "N/A"
return last_period_type
def calculate_game_priority(game):
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"] or game["clock"]["inIntermission"]:
return 0
else:
# Get standings information from the database for home and away teams
home_team_standings = get_team_standings(game["homeTeam"]["name"]["default"])
away_team_standings = get_team_standings(game["awayTeam"]["name"]["default"])
# Calculate total values of leagueSequence + leagueL10Sequence for each team
home_team_total = home_team_standings["league_sequence"] + home_team_standings["league_l10_sequence"]
away_team_total = away_team_standings["league_sequence"] + away_team_standings["league_l10_sequence"]
# Calculate the priority adjustment factor by subtracting away team's total from home team's total
matchup_adjustment = home_team_total + away_team_total
period = game.get("periodDescriptor", {}).get("number", 0)
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"]
score_difference = abs(home_score - away_score)
score_total = (home_score + away_score) * 20
if period == 4:
base_priority = 400
elif period == 3:
base_priority = 300
elif period == 2:
base_priority = 200
else:
base_priority = 100
if score_difference > 3:
base_priority -= 500
elif score_difference > 2:
base_priority -= 350
elif score_difference > 1:
base_priority -= 100
if score_difference == 0 and period == 3 and time_remaining <= 600:
base_priority += 100
time_priority = (1200 - time_remaining) / 20
# Add the priority adjustment factor to the base priority
return int(base_priority + time_priority - matchup_adjustment + score_total)
def get_team_standings(team_name):
conn = sqlite3.connect("nhl_standings.db")
cursor = conn.cursor()
cursor.execute("""
SELECT league_sequence, league_l10_sequence
FROM standings
WHERE team_common_name = ?
""", (team_name,))
result = cursor.fetchone()
conn.close()
if result:
return {"league_sequence": result[0], "league_l10_sequence": result[1]}
else:
return {"league_sequence": 0, "league_l10_sequence": 0}
def utc_to_est_time(utc_time):
utc_datetime = datetime.strptime(utc_time, "%Y-%m-%dT%H:%M:%SZ")
est_offset = timedelta(hours=-5)
est_datetime = utc_datetime + est_offset
est_time_str = est_datetime.strftime("%I:%M %p")
return est_time_str
# Routes
@app.route('/')
def index():
return render_template('index.html')
@app.route('/scoreboard')
def get_scoreboard():
global scoreboard_data
if scoreboard_data:
live_games = [game for game in extract_game_info() if game["Game State"] == "LIVE"]
pre_games = [game for game in extract_game_info() if game["Game State"] == "PRE"]
final_games = [game for game in extract_game_info() if game["Game State"] == "FINAL"]
return jsonify({
"live_games": live_games,
"pre_games": pre_games,
"final_games": final_games
})
else:
return jsonify({"error": "Failed to retrieve scoreboard data"})
if __name__ == '__main__':
store_scoreboard_data()
threading.Thread(target=schedule_task).start()
serve(app, host="0.0.0.0", port=2897)
+13
View File
@@ -0,0 +1,13 @@
import logging
from flask import Flask
from app.config import LOG_LEVEL
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
app = Flask(__name__)
from app import routes # noqa: E402, F401
+40
View File
@@ -0,0 +1,40 @@
import json
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
import requests
from app.config import SCOREBOARD_DATA_FILE
logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York")
def fetch_scores():
now = datetime.now(EASTERN)
start_time_evening = now.replace(hour=19, minute=0, second=0, microsecond=0)
end_time_morning = now.replace(hour=3, minute=0, second=0, microsecond=0)
if now >= start_time_evening or now < end_time_morning:
nhle_api_url = "https://api-web.nhle.com/v1/score/now"
else:
nhle_api_url = f"https://api-web.nhle.com/v1/score/{now.strftime('%Y-%m-%d')}"
try:
response = requests.get(nhle_api_url, timeout=10)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
logger.error("Failed to fetch scoreboard data from %s: %s", nhle_api_url, e)
return None
def refresh_scores():
scoreboard_data = fetch_scores()
if scoreboard_data:
with open(SCOREBOARD_DATA_FILE, "w") as json_file:
json.dump(scoreboard_data, json_file)
return scoreboard_data
return None
+8
View File
@@ -0,0 +1,8 @@
import os
DATA_DIR = os.environ.get("DATA_DIR", "app/data")
PORT = int(os.environ.get("PORT", 2897))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
SCOREBOARD_DATA_FILE = os.path.join(DATA_DIR, "scoreboard_data.json")
DB_PATH = os.path.join(DATA_DIR, "nhl_standings.db")
+540
View File
@@ -0,0 +1,540 @@
import logging
import sqlite3
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.config import DB_PATH
EASTERN = ZoneInfo("America/New_York")
logger = logging.getLogger(__name__)
# Maps (home_team_name, away_team_name) -> (home_score, away_score)
_score_cache: dict[tuple[str, str], tuple[int, int]] = {}
# Maps (home_team_name, away_team_name) -> max score differential seen
_comeback_tracker: dict[tuple[str, str], int] = {}
def format_record(record):
if record == "N/A":
return "N/A"
else:
parts = record.split("-")
formatted_parts = [part.zfill(2) for part in parts]
return "-".join(formatted_parts)
def parse_games(scoreboard_data):
if not scoreboard_data:
return []
extracted_info = []
for game in scoreboard_data.get("games", []):
game_state = convert_game_state(game["gameState"])
priority_comps = _priority_components(game)
comeback = get_comeback_bonus(game)
importance_comps = _importance_components(game)
total_priority = priority_comps["total"] + comeback + importance_comps["total"]
extracted_info.append(
{
"Home Team": game["homeTeam"]["name"]["default"],
"Home Score": game["homeTeam"]["score"]
if game_state != "PRE"
else "N/A",
"Away Team": game["awayTeam"]["name"]["default"],
"Away Score": game["awayTeam"]["score"]
if game_state != "PRE"
else "N/A",
"Home Logo": game["homeTeam"]["logo"],
"Away Logo": game["awayTeam"]["logo"],
"Game State": game_state,
"Game Type": game.get("gameType", 2),
"Period": get_period(game),
"Time Remaining": get_time_remaining(game),
"Time Running": game["clock"]["running"]
if game_state == "LIVE"
else "N/A",
"Intermission": game["clock"]["inIntermission"]
if game_state == "LIVE"
else "N/A",
"Priority": total_priority,
"Hype Breakdown": {
"base": priority_comps["base"],
"time": priority_comps["time"],
"matchup_bonus": priority_comps["matchup_bonus"],
"closeness": priority_comps["closeness"],
"power_play": priority_comps["power_play"],
"empty_net": priority_comps["empty_net"],
"comeback": comeback,
"importance": importance_comps["total"],
"importance_season_weight": importance_comps["season_weight"],
"importance_playoff_relevance": importance_comps[
"playoff_relevance"
],
"importance_rivalry": importance_comps["rivalry"],
"total": total_priority,
},
"Start Time": get_start_time(game),
"Home Record": format_record(game["homeTeam"]["record"])
if game["gameState"] in ["PRE", "FUT"]
else "N/A",
"Away Record": format_record(game["awayTeam"]["record"])
if game["gameState"] in ["PRE", "FUT"]
else "N/A",
"Home Shots": game["homeTeam"]["sog"]
if game["gameState"] not in ["PRE", "FUT"]
else 0,
"Away Shots": game["awayTeam"]["sog"]
if game["gameState"] not in ["PRE", "FUT"]
else 0,
"Home Power Play": get_power_play_info(
game, game["homeTeam"]["name"]["default"]
),
"Away Power Play": get_power_play_info(
game, game["awayTeam"]["name"]["default"]
),
"Last Period Type": get_game_outcome(game, game_state),
}
)
# Sort games based on priority
return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True)
def get_comeback_bonus(game):
"""Persistent comeback bonus that scales with deficit recovered.
Tracks the maximum score differential seen in the game. A recovery of 2+
goals earns a sustained bonus that persists as long as the game remains
close. One-goal swings are normal hockey and earn no bonus.
"""
if game["gameState"] not in ("LIVE", "CRIT"):
return 0
if game["clock"]["inIntermission"]:
return 0
home_name = game["homeTeam"]["name"]["default"]
away_name = game["awayTeam"]["name"]["default"]
key = (home_name, away_name)
home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"]
current_diff = abs(home_score - away_score)
period = game.get("periodDescriptor", {}).get("number", 0)
tracker_max = _comeback_tracker.get(key, 0)
if key in _score_cache:
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
tracker_max = max(tracker_max, prev_diff)
_comeback_tracker[key] = tracker_max
_score_cache[key] = (home_score, away_score)
recovery = tracker_max - current_diff
if recovery < 2 or tracker_max < 2:
return 0
base = {2: 60, 3: 120}.get(recovery, 160)
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
tie_bonus = 30 if current_diff == 0 else 0
return int(base * period_mult + tie_bonus)
def convert_game_state(game_state):
state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"}
return state_mapping.get(game_state, game_state)
def get_period(game):
if game["gameState"] in ["PRE", "FUT"]:
return 0
elif game["gameState"] in ["FINAL", "OFF"]:
return "N/A"
else:
return game["periodDescriptor"]["number"]
def get_time_remaining(game):
if game["gameState"] in ["PRE", "FUT"]:
return "20:00"
elif game["gameState"] in ["FINAL", "OFF"]:
return "00:00"
else:
time_remaining = game["clock"]["timeRemaining"]
return "END" if time_remaining == "00:00" else time_remaining
def get_start_time(game):
if game["gameState"] in ["PRE", "FUT"]:
utc_time = game["startTimeUTC"]
est_time = utc_to_eastern(utc_time)
return est_time.lstrip("0")
else:
return "N/A"
def get_power_play_info(game, team_name):
situation = game.get("situation", {})
if not situation:
return ""
time_remaining = situation.get("timeRemaining", "")
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
if "PP" in home_descs and game["homeTeam"]["name"]["default"] == team_name:
return f"PP {time_remaining}"
if "PP" in away_descs and game["awayTeam"]["name"]["default"] == team_name:
return f"PP {time_remaining}"
return ""
def get_game_outcome(game, game_state):
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
def _get_man_advantage(situation):
"""Parse situationCode for player count difference.
Format: [away_goalie][away_skaters][home_skaters][home_goalie]."""
code = situation.get("situationCode", "")
if len(code) != 4 or not code.isdigit():
return 1
away_total = int(code[0]) + int(code[1])
home_total = int(code[2]) + int(code[3])
return abs(home_total - away_total)
def _priority_components(game):
"""Return a dict of all priority components plus the final total."""
_zero = {
"base": 0,
"time": 0,
"matchup_bonus": 0,
"closeness": 0,
"power_play": 0,
"empty_net": 0,
"total": 0,
}
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
return _zero
period = game.get("periodDescriptor", {}).get("number", 0)
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"]
score_difference = abs(home_score - away_score)
is_playoff = game.get("gameType", 2) == 3
# ── 1. Base priority by period ────────────────────────────────────────
if is_playoff:
base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150)
else:
base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150)
# ── 2. Period length for time calculations ────────────────────────────
period_length = (1200 if is_playoff else 300) if period >= 4 else 1200
# ── 3. Standings-quality matchup bonus ───────────────────────────────
# Invert rank so that #1 (best) contributes the most quality points.
# league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32.
home_standings = get_team_standings(game["homeTeam"]["name"]["default"])
away_standings = get_team_standings(game["awayTeam"]["name"]["default"])
home_quality = (33 - home_standings["league_sequence"]) + (
33 - home_standings["league_l10_sequence"]
)
away_quality = (33 - away_standings["league_sequence"]) + (
33 - away_standings["league_l10_sequence"]
)
# Higher period = matchup matters less (any OT is exciting regardless of teams)
matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0)
matchup_bonus = (home_quality + away_quality) * matchup_multiplier
# ── Shootout: flat priority, no time component (rounds, not clock) ───
if period == 5 and not is_playoff:
so_base = 550
so_closeness = 80
so_matchup = (home_quality + away_quality) * 1.0
so_total = int(so_base + so_closeness + so_matchup)
return {
"base": so_base,
"time": 0,
"matchup_bonus": int(so_matchup),
"closeness": so_closeness,
"power_play": 0,
"empty_net": 0,
"total": so_total,
}
# ── 4. Score-differential penalty (period-aware) ───────────────────────
score_differential_adjustment = 0
if period <= 2:
adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350}
score_differential_adjustment = adj.get(
score_difference, 350 + (score_difference - 4) * 100
)
elif period == 3:
mins_left = time_remaining / 60
if mins_left > 10:
adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400}
elif mins_left > 5:
adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500}
elif mins_left > 2:
# Goalie-pull zone: 2-goal penalty DECREASES
adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600}
else:
# Final 2 min: 2-goal deficit with active goalie pull is exciting
adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700}
score_differential_adjustment = adj.get(
score_difference, adj[4] + (score_difference - 4) * 100
)
# OT: always tied, no penalty needed
base_priority -= score_differential_adjustment
# ── 5. Late-3rd urgency bonus ─────────────────────────────────────────
if period == 3 and time_remaining <= 720:
if score_difference == 0:
base_priority += 100
elif score_difference == 1:
base_priority += 60
if period == 3 and time_remaining <= 360:
if score_difference == 0:
base_priority += 50
elif score_difference == 1:
base_priority += 30
# ── 6. Closeness bonus ───────────────────────────────────────────────
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
# ── 7. Time priority (non-linear — final minutes weighted more) ─────
time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5)
elapsed_fraction = (
max(0.0, (period_length - time_remaining) / period_length)
if period_length
else 0
)
time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier
# ── 8. Power play bonus ───────────────────────────────────────────────
pp_bonus = 0
situation = game.get("situation", {})
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
if "PP" in home_descs or "PP" in away_descs:
man_advantage = _get_man_advantage(situation)
advantage_mult = 1.0 if man_advantage <= 1 else 1.6
if period >= 4:
pp_bonus = int(200 * advantage_mult)
elif period == 3 and time_remaining <= 300:
pp_bonus = int(150 * advantage_mult)
elif period == 3 and time_remaining <= 720:
pp_bonus = int(100 * advantage_mult)
elif period == 3:
pp_bonus = int(50 * advantage_mult)
else:
pp_bonus = int(30 * advantage_mult)
# ── 9. Empty net bonus ───────────────────────────────────────────────
en_bonus = 0
if "EN" in home_descs or "EN" in away_descs:
if period >= 4:
en_bonus = 250
elif period == 3 and time_remaining <= 180:
en_bonus = 200
elif period == 3 and time_remaining <= 360:
en_bonus = 150
else:
en_bonus = 75
logger.debug(
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
"closeness: %s, pp: %s, en: %s",
base_priority,
time_priority,
matchup_bonus,
closeness_bonus,
pp_bonus,
en_bonus,
)
final_priority = int(
base_priority
+ time_priority
+ matchup_bonus
+ closeness_bonus
+ pp_bonus
+ en_bonus
)
# Pushes intermission games to the bottom, retains relative sort order
if game["clock"]["inIntermission"]:
return {**_zero, "total": -2000 - time_remaining}
return {
"base": base_priority,
"time": int(time_priority),
"matchup_bonus": int(matchup_bonus),
"closeness": closeness_bonus,
"power_play": pp_bonus,
"empty_net": en_bonus,
"total": final_priority,
}
def calculate_game_priority(game):
return _priority_components(game)["total"]
def get_team_standings(team_name):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT league_sequence, league_l10_sequence,
division_abbrev, conference_abbrev,
games_played, wildcard_sequence
FROM standings
WHERE team_common_name = ?
""",
(team_name,),
)
result = cursor.fetchone()
conn.close()
if result:
return {
"league_sequence": result[0],
"league_l10_sequence": result[1],
"division_abbrev": result[2],
"conference_abbrev": result[3],
"games_played": result[4],
"wildcard_sequence": result[5],
}
return {
"league_sequence": 0,
"league_l10_sequence": 0,
"division_abbrev": None,
"conference_abbrev": None,
"games_played": 0,
"wildcard_sequence": 32,
}
def _playoff_importance(game):
"""Importance for playoff games based on series context and round."""
series = game.get("seriesStatus", {})
if not series:
# No series data available — flat playoff bonus
return {
"season_weight": 1.0,
"playoff_relevance": 0.50,
"rivalry": 1.0,
"total": 100,
}
round_num = series.get("round", 1)
top_wins = series.get("topSeedWins", 0)
bottom_wins = series.get("bottomSeedWins", 0)
max_wins = max(top_wins, bottom_wins)
min_wins = min(top_wins, bottom_wins)
round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0)
if max_wins == 3 and min_wins == 3:
series_factor = 1.0
elif max_wins == 3:
series_factor = 0.85
elif max_wins == 2 and min_wins == 2:
series_factor = 0.70
elif max_wins == 2:
series_factor = 0.55
else:
series_factor = 0.40
importance = min(int(series_factor * round_mult * 200), 200)
return {
"season_weight": round_mult,
"playoff_relevance": series_factor,
"rivalry": 1.0,
"total": importance,
}
def _importance_components(game):
"""Return a dict of all importance components plus the final total."""
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
if game["gameState"] in ("FINAL", "OFF"):
return _zero
if game.get("gameType", 2) == 3:
return _playoff_importance(game)
if game.get("gameType", 2) != 2:
return _zero
home_st = get_team_standings(game["homeTeam"]["name"]["default"])
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
# Season weight — near-zero before game 30, sharp ramp 55-70, max at 82
avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2
if avg_gp <= 30:
season_weight = 0.05
else:
t = (avg_gp - 30) / (82 - 30)
season_weight = min(t**1.8, 1.0)
# Playoff relevance — peaks for bubble teams (wildcard rank ~17-19)
best_wc = min(
home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32
)
if best_wc <= 12:
playoff_relevance = 0.60
elif best_wc <= 16:
playoff_relevance = 0.85
elif best_wc <= 19:
playoff_relevance = 1.00
elif best_wc <= 23:
playoff_relevance = 0.65
else:
playoff_relevance = 0.15
# Division/conference rivalry multiplier
home_div = home_st["division_abbrev"]
away_div = away_st["division_abbrev"]
home_conf = home_st["conference_abbrev"]
away_conf = away_st["conference_abbrev"]
if home_div and away_div and home_div == away_div:
rivalry_multiplier = 1.4
elif home_conf and away_conf and home_conf == away_conf:
rivalry_multiplier = 1.2
else:
rivalry_multiplier = 1.0
raw = season_weight * playoff_relevance * rivalry_multiplier
importance = max(0, min(int((raw / 1.4) * 150), 150))
logger.debug(
"importance components — season_weight: %.3f, playoff_relevance: %.2f, "
"rivalry: %.1f, importance: %s",
season_weight,
playoff_relevance,
rivalry_multiplier,
importance,
)
return {
"season_weight": round(season_weight, 3),
"playoff_relevance": playoff_relevance,
"rivalry": rivalry_multiplier,
"total": importance,
}
def calculate_game_importance(game):
return _importance_components(game)["total"]
def utc_to_eastern(utc_time):
utc_datetime = datetime.strptime(utc_time, "%Y-%m-%dT%H:%M:%SZ")
eastern_datetime = utc_datetime.replace(tzinfo=timezone.utc).astimezone(EASTERN)
return eastern_datetime.strftime("%I:%M %p")
+64
View File
@@ -0,0 +1,64 @@
import json
from flask import render_template, jsonify, send_from_directory
from app import app
from app.config import SCOREBOARD_DATA_FILE
from app.games import parse_games
@app.route("/manifest.json")
def manifest():
return send_from_directory(app.static_folder, "manifest.json")
@app.route("/sw.js")
def service_worker():
response = send_from_directory(app.static_folder, "sw.js")
response.headers["Service-Worker-Allowed"] = "/"
response.headers["Cache-Control"] = "no-cache"
return response
@app.route("/favicon.ico")
def favicon():
return send_from_directory(
app.static_folder, "icon-32x32.png", mimetype="image/png"
)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/scoreboard")
def get_scoreboard():
try:
with open(SCOREBOARD_DATA_FILE, "r") as json_file:
scoreboard_data = json.load(json_file)
except FileNotFoundError:
return jsonify({"error": "Failed to retrieve scoreboard data. File not found."})
except json.JSONDecodeError:
return jsonify(
{"error": "Failed to retrieve scoreboard data. Invalid JSON format."}
)
if scoreboard_data:
games = parse_games(scoreboard_data)
return jsonify(
{
"live_games": [
g
for g in games
if g["Game State"] == "LIVE" and not g["Intermission"]
],
"intermission_games": [
g for g in games if g["Game State"] == "LIVE" and g["Intermission"]
],
"pre_games": [g for g in games if g["Game State"] == "PRE"],
"final_games": [g for g in games if g["Game State"] == "FINAL"],
}
)
else:
return jsonify({"error": "Failed to retrieve scoreboard data"})
+21
View File
@@ -0,0 +1,21 @@
import logging
import time
import schedule
from app.api import refresh_scores
from app.standings import refresh_standings
logger = logging.getLogger(__name__)
def start_scheduler():
schedule.every(600).seconds.do(refresh_standings)
schedule.every(10).seconds.do(refresh_scores)
logger.info("Background scheduler started")
while True:
try:
schedule.run_pending()
except Exception:
logger.exception("Scheduler encountered an unexpected error")
time.sleep(1)
+105
View File
@@ -0,0 +1,105 @@
import logging
import sqlite3
import requests
from app.config import DB_PATH
logger = logging.getLogger(__name__)
def create_standings_table(conn):
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS standings (
team_common_name TEXT,
league_sequence INTEGER,
league_l10_sequence INTEGER,
division_abbrev TEXT,
conference_abbrev TEXT,
games_played INTEGER,
wildcard_sequence INTEGER
)
""")
conn.commit()
def migrate_standings_table(conn):
cursor = conn.cursor()
for col_name, col_type in [
("division_abbrev", "TEXT"),
("conference_abbrev", "TEXT"),
("games_played", "INTEGER"),
("wildcard_sequence", "INTEGER"),
]:
try:
cursor.execute(f"ALTER TABLE standings ADD COLUMN {col_name} {col_type}")
conn.commit()
except sqlite3.OperationalError:
pass # Column already exists
def truncate_standings_table(conn):
cursor = conn.cursor()
cursor.execute("DELETE FROM standings")
conn.commit()
def insert_standings(conn, standings):
cursor = conn.cursor()
for team in standings:
cursor.execute(
"""
INSERT INTO standings (
team_common_name, league_sequence, league_l10_sequence,
division_abbrev, conference_abbrev, games_played, wildcard_sequence
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
team["team_common_name"],
team["league_sequence"],
team["league_l10_sequence"],
team["division_abbrev"],
team["conference_abbrev"],
team["games_played"],
team["wildcard_sequence"],
),
)
conn.commit()
def fetch_standings():
url = "https://api-web.nhle.com/v1/standings/now"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
standings_data = response.json()
standings = []
for team in standings_data.get("standings", []):
standings.append(
{
"team_common_name": team["teamCommonName"]["default"],
"league_sequence": team["leagueSequence"],
"league_l10_sequence": team["leagueL10Sequence"],
"division_abbrev": team["divisionAbbrev"],
"conference_abbrev": team["conferenceAbbrev"],
"games_played": team["gamesPlayed"],
"wildcard_sequence": team["wildcardSequence"],
}
)
return standings
except requests.RequestException as e:
logger.error("Failed to fetch standings: %s", e)
return None
def refresh_standings():
conn = sqlite3.connect(DB_PATH)
create_standings_table(conn)
migrate_standings_table(conn)
standings = fetch_standings()
if standings:
truncate_standings_table(conn)
insert_standings(conn, standings)
conn.close()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "NHL Scoreboard",
"short_name": "NHL Scores",
"description": "Live NHL game scores ranked by hype",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"orientation": "portrait-primary",
"icons": [
{
"src": "/static/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+249
View File
@@ -0,0 +1,249 @@
async function fetchScoreboardData() {
try {
const res = await fetch('/scoreboard');
if (!res.ok) throw new Error(res.status);
updateScoreboard(await res.json());
} catch (e) {
console.error('Failed to fetch scoreboard data:', e);
}
}
function updateScoreboard(data) {
const sections = [
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_games, render: renderLiveGame },
{ sectionId: 'intermission-section', gridId: 'intermission-games-section', games: data.intermission_games, render: renderLiveGame },
{ sectionId: 'pre-section', gridId: 'pre-games-section', games: data.pre_games, render: renderPreGame },
{ sectionId: 'final-section', gridId: 'final-games-section', games: data.final_games, render: renderFinalGame },
];
for (const { sectionId, gridId, games, render } of sections) {
const section = document.getElementById(sectionId);
const grid = document.getElementById(gridId);
const hasGames = games && games.length > 0;
section.classList.toggle('hidden', !hasGames);
// Snapshot current clock state before blowing away the DOM
const clockSnapshot = snapshotClocks(grid);
grid.innerHTML = hasGames ? games.map(render).join('') : '';
// Restore smooth local anchors unless we're in the final 60s
if (hasGames) restoreClocks(grid, clockSnapshot);
}
updateGauges();
}
// ── Renderers ────────────────────────────────────────
function renderLiveGame(game) {
const intermission = game['Intermission'];
const period = game['Period'];
const time = game['Time Remaining'];
const running = game['Time Running'];
const periodLabel = intermission
? `<span class="badge">${intermissionLabel(period)}</span>`
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
const dot = running ? `<span class="live-dot"></span>` : '';
// Tick the clock locally when the clock is running or during intermission
const shouldTick = running || intermission;
const rawSeconds = timeToSeconds(time);
const clockAttrs = shouldTick
? `data-seconds="${rawSeconds}" data-received-at="${Date.now()}"`
: '';
const hype = !intermission ? `
<div class="hype-meter">
<span class="hype-label">Hype Meter</span>
<div class="gauge-track">
<div class="gauge" data-score="${game['Priority']}"></div>
</div>
</div>` : '';
return `
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
<div class="card-header">
<div class="badges">
${periodLabel}
<span class="badge" ${clockAttrs}>${time}</span>
</div>
${dot}
</div>
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${ppIndicator(game)}
${hype}
</div>`;
}
function renderPreGame(game) {
return `
<div class="game-box">
<div class="card-header">
<div class="badges">
<span class="badge">${game['Start Time']}</span>
</div>
</div>
${teamRow(game, 'Away', 'pre')}
${teamRow(game, 'Home', 'pre')}
</div>`;
}
function renderFinalGame(game) {
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
const label = labels[game['Last Period Type']] ?? 'Final';
return `
<div class="game-box">
<div class="card-header">
<div class="badges">
<span class="badge badge-muted">${label}</span>
</div>
</div>
${teamRow(game, 'Away', 'final')}
${teamRow(game, 'Home', 'final')}
</div>`;
}
// ── Team Row ─────────────────────────────────────────
function teamRow(game, side, state) {
const name = game[`${side} Team`];
const logo = game[`${side} Logo`];
const score = game[`${side} Score`];
const sog = game[`${side} Shots`];
const record = game[`${side} Record`];
const sogHtml = (state === 'live' || state === 'final') && sog !== undefined
? `<span class="team-sog">${sog} SOG</span>` : '';
const right = state === 'pre'
? `<span class="team-record">${record}</span>`
: `<span class="team-score">${score}</span>`;
return `
<div class="team-row">
<img src="${logo}" alt="${name} logo" class="team-logo">
<div class="team-meta">
<span class="team-name">${name}</span>
${sogHtml}
</div>
${right}
</div>`;
}
function ppIndicator(game) {
const awayPP = game['Away Power Play'];
const homePP = game['Home Power Play'];
const pp = awayPP || homePP;
if (!pp) return '';
const team = awayPP ? game['Away Team'] : game['Home Team'];
const timeStr = pp.replace('PP ', '');
const seconds = timeToSeconds(timeStr);
const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`;
return `
<div class="pp-indicator">
<span class="pp-label">PP</span>
<span class="pp-team">${team}</span>
<span class="pp-clock" ${attrs}>${timeStr}</span>
</div>`;
}
// ── Gauge ────────────────────────────────────────────
function updateGauges() {
document.querySelectorAll('.gauge').forEach(el => {
const score = Math.min(1000, Math.max(0, parseInt(el.dataset.score, 10)));
el.style.width = `${(score / 1000) * 100}%`;
el.style.backgroundColor = score <= 350 ? '#4a90e2'
: score <= 650 ? '#f97316'
: '#ef4444';
});
}
const CLOCK_SYNC_THRESHOLD = 60; // seconds — only resync from API in final 60s
// ── Clock ─────────────────────────────────────────────
function timeToSeconds(str) {
if (!str || str === 'END') return 0;
const [m, s] = str.split(':').map(Number);
return m * 60 + s;
}
function secondsToTime(s) {
if (s <= 0) return 'END';
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
function snapshotClocks(grid) {
const snapshot = new Map();
grid.querySelectorAll('[data-game-key]').forEach(card => {
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
if (!badge) return;
const seconds = parseInt(badge.dataset.seconds, 10);
const receivedAt = parseInt(badge.dataset.receivedAt, 10);
const elapsed = Math.floor((Date.now() - receivedAt) / 1000);
const current = Math.max(0, seconds - elapsed);
snapshot.set(card.dataset.gameKey, { current, ts: Date.now() });
});
return snapshot;
}
function restoreClocks(grid, snapshot) {
grid.querySelectorAll('[data-game-key]').forEach(card => {
const prior = snapshot.get(card.dataset.gameKey);
if (!prior) return;
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
if (!badge) return;
// Only restore if we're outside the final sync window
if (prior.current > CLOCK_SYNC_THRESHOLD) {
badge.dataset.seconds = prior.current;
badge.dataset.receivedAt = prior.ts;
badge.textContent = secondsToTime(prior.current);
}
});
}
function tickClocks() {
const now = Date.now();
document.querySelectorAll('[data-seconds][data-received-at]').forEach(el => {
const seconds = parseInt(el.dataset.seconds, 10);
const receivedAt = parseInt(el.dataset.receivedAt, 10);
const elapsed = Math.floor((now - receivedAt) / 1000);
el.textContent = secondsToTime(Math.max(0, seconds - elapsed));
});
}
// ── Helpers ──────────────────────────────────────────
function ordinalPeriod(period) {
return ['1st', '2nd', '3rd', 'OT'][period - 1] ?? 'SO';
}
function intermissionLabel(period) {
return ['1st Int', '2nd Int', '3rd Int'][period - 1] ?? 'Int';
}
// ── Init ─────────────────────────────────────────────
function autoRefresh() {
fetchScoreboardData();
setTimeout(autoRefresh, 5000);
}
window.addEventListener('load', () => {
autoRefresh();
setInterval(tickClocks, 1000);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
}
});
+386
View File
@@ -0,0 +1,386 @@
:root {
--bg: #111;
--card: #1c1c1c;
--card-border: #2a2a2a;
--badge-bg: #2a2a2a;
--text: #f0f0f0;
--text-muted: #666;
--green-bg: #14532d;
--green-text: #86efac;
--green-accent: #22c55e;
--red: #ef4444;
--gap: 1rem;
--radius: 12px;
--card-w: 290px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100vh;
}
/* ── Header ─────────────────────────────────────── */
header {
padding: 1rem 1.25rem 0.25rem;
text-align: center;
}
.header-title {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
/* ── Layout ─────────────────────────────────────── */
main {
padding: 0.75rem 1.25rem 2rem;
max-width: 1800px;
margin-left: auto;
margin-right: auto;
}
.section {
margin-bottom: 2rem;
}
.section.hidden {
display: none;
}
.section-heading {
font-size: 0.7rem;
font-weight: 700;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-muted);
margin-bottom: 0.875rem;
margin-top: 0.25rem;
}
.games-grid {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
justify-content: center;
}
/* ── Game Card ──────────────────────────────────── */
.game-box {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 1rem 1rem 0.875rem;
width: var(--card-w);
flex-shrink: 0;
border-top-width: 3px;
}
.game-box-live {
border-top-color: var(--green-accent);
}
.game-box-intermission {
border-top-color: #f59e0b;
}
/* ── Card Header (badges + live dot) ───────────── */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
min-height: 1.25rem;
}
.badges {
display: flex;
gap: 0.3rem;
align-items: center;
flex-wrap: wrap;
}
.badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.2rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--badge-bg);
color: var(--text);
white-space: nowrap;
}
.badge-live {
background: var(--green-bg);
color: var(--green-text);
}
.badge-muted {
color: var(--text-muted);
}
.live-dot {
width: 7px;
height: 7px;
background: var(--red);
border-radius: 50%;
flex-shrink: 0;
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Team Rows ──────────────────────────────────── */
.team-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.55rem 0;
}
.team-row + .team-row {
border-top: 1px solid var(--card-border);
}
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
.team-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.team-name {
font-size: 0.825rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.team-sog {
font-size: 0.68rem;
color: var(--text-muted);
}
.team-pp {
font-size: 0.68rem;
color: var(--red);
font-weight: 600;
}
.team-score {
font-size: 1.6rem;
font-weight: 700;
margin-left: auto;
flex-shrink: 0;
min-width: 1.75rem;
text-align: right;
letter-spacing: -0.02em;
}
.team-record {
font-size: 0.72rem;
color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
white-space: nowrap;
}
/* ── Power Play Indicator ───────────────────────── */
.pp-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
}
.pp-label {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--red);
text-transform: uppercase;
flex-shrink: 0;
}
.pp-team {
font-size: 0.72rem;
font-weight: 600;
color: var(--text);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pp-clock {
font-size: 0.72rem;
font-weight: 700;
color: var(--red);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* ── Hype Meter ─────────────────────────────────── */
.hype-meter {
margin-top: 0.75rem;
}
.hype-label {
display: block;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.gauge-track {
height: 4px;
background: var(--badge-bg);
border-radius: 99px;
overflow: hidden;
}
.gauge {
height: 100%;
border-radius: 99px;
width: 0;
transition: width 0.5s ease;
}
/* ── Desktop ────────────────────────────────────── */
@media (min-width: 900px) {
:root {
--card-w: 340px;
--gap: 1.25rem;
}
main {
padding: 1rem 2rem 2.5rem;
}
.header-title {
font-size: 2.4rem;
}
.section-heading {
font-size: 0.85rem;
}
.game-box {
padding: 1.125rem 1.125rem 1rem;
}
.team-logo {
width: 48px;
height: 48px;
}
.badge {
font-size: 0.75rem;
}
.team-name {
font-size: 0.95rem;
}
.team-score {
font-size: 1.9rem;
}
.hype-label {
font-size: 0.7rem;
}
}
@media (min-width: 1400px) {
:root {
--card-w: 400px;
--gap: 1.5rem;
}
main {
padding: 1.25rem 2.5rem 3rem;
}
.header-title {
font-size: 2.8rem;
}
.section-heading {
font-size: 0.95rem;
}
.game-box {
padding: 1.25rem 1.25rem 1.125rem;
}
.team-logo {
width: 56px;
height: 56px;
}
.badge {
font-size: 0.82rem;
}
.team-name {
font-size: 1.05rem;
}
.team-score {
font-size: 2.2rem;
}
.hype-label {
font-size: 0.76rem;
}
}
/* ── Mobile ─────────────────────────────────────── */
@media (max-width: 640px) {
:root {
--card-w: 100%;
}
.games-grid {
flex-direction: column;
}
}
+49
View File
@@ -0,0 +1,49 @@
const CACHE = 'nhl-scoreboard-v1';
const PRECACHE = [
'/',
'/static/styles.css',
'/static/script.js',
'/static/icon-192x192.png',
'/static/icon-512x512.png',
'/manifest.json',
];
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
const { pathname } = new URL(event.request.url);
// Network-first for the live scoreboard API — stale data is useless
if (pathname === '/scoreboard') {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
// Cache-first for everything else (static assets, shell)
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(event.request, clone));
}
return response;
});
})
);
});
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>NHL Scoreboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NHL Scores">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
<link rel="apple-touch-icon" href="/static/icon-180x180.png">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
</head>
<body>
<header>
<span class="header-title">NHL Scoreboard</span>
</header>
<main>
<section id="live-section" class="section hidden">
<h2 class="section-heading">Live</h2>
<div id="live-games-section" class="games-grid"></div>
</section>
<section id="intermission-section" class="section hidden">
<h2 class="section-heading">Intermission</h2>
<div id="intermission-games-section" class="games-grid"></div>
</section>
<section id="pre-section" class="section hidden">
<h2 class="section-heading">Scheduled</h2>
<div id="pre-games-section" class="games-grid"></div>
</section>
<section id="final-section" class="section hidden">
<h2 class="section-heading">Final</h2>
<div id="final-games-section" class="games-grid"></div>
</section>
</main>
<script src="/static/script.js"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
services:
nhl-scoreboard:
image: gitea.thewrightserver.net/josh/nhlscoreboard:latest
ports:
- "2897:2897"
volumes:
- ./data:/app/app/data
environment:
- DATA_DIR=/app/app/data
- LOG_LEVEL=INFO
restart: unless-stopped
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
pytest==8.3.4
pytest-cov==6.0.0
pytest-mock==3.14.0
ruff==0.8.6
+6 -6
View File
@@ -1,6 +1,6 @@
Flask==3.0.2
Jinja2==3.1.3
requests==2.31.0
Werkzeug==3.0.1
waitress==3.0.0
schedule==1.2.1
Flask==3.1.0
Jinja2==3.1.4
requests==2.32.3
Werkzeug==3.1.3
waitress==3.0.1
schedule==1.2.2
+15
View File
@@ -0,0 +1,15 @@
import threading
from waitress import serve
from app import app
from app.api import refresh_scores
from app.config import PORT
from app.scheduler import start_scheduler
from app.standings import refresh_standings
if __name__ == "__main__":
refresh_scores()
refresh_standings()
threading.Thread(target=start_scheduler, daemon=True).start()
serve(app, host="0.0.0.0", port=PORT)
-166
View File
@@ -1,166 +0,0 @@
// Function to fetch scoreboard data using AJAX
function fetchScoreboardData() {
var xhr = new XMLHttpRequest();
xhr.open("GET", "/scoreboard", true);
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
updateScoreboard(JSON.parse(xhr.responseText));
} else {
console.error("Failed to fetch scoreboard data.");
}
}
};
xhr.send();
}
// Function to update scoreboard with fetched data
function updateScoreboard(data) {
var liveGamesSection = document.getElementById("live-games-section");
var preGamesSection = document.getElementById('pre-games-section');
var finalGamesSection = document.getElementById('final-games-section');
if (liveGamesSection) {
var liveGamesExist = data && data.live_games && data.live_games.length > 0;
if (liveGamesExist) {
document.getElementById('live-games').innerText = "Live Games"
liveGamesSection.innerHTML = generateGameBoxes(data.live_games, 'LIVE');
}
}
if (preGamesSection) {
var preGamesExist = data && data.pre_games && data.pre_games.length > 0;
if (preGamesExist) {
document.getElementById('on-later').innerText = "On Later"
preGamesSection.innerHTML = generateGameBoxes(data.pre_games, 'PRE');
}
}
if (finalGamesSection) {
var finalGamesExist = data && data.final_games && data.final_games.length > 0;
if (finalGamesExist) {
document.getElementById('game-over').innerText = "Game Over"
finalGamesSection.innerHTML = generateGameBoxes(data.final_games, 'FINAL');
}
}
}
// Function to generate HTML for game boxes
function generateGameBoxes(games, state) {
var html = '';
games.forEach(function(game) {
if (game['Game State'] === state) {
html += '<div class="game-box">';
if (state === 'LIVE') {
if (game['Time Running']) {
html += '<div class="live-dot"></div>'; // Display the red dot if the game is live
}
html += '<div class="team-info">';
html += '<img src="' + game['Away Logo'] + '" alt="' + game['Away Team'] + ' Logo" class="team-logo">';
html += '<div class="team-info-column">';
html += '<span class="team-name">' + game['Away Team'] + '</span>';
html += '<span class="team-sog">SOG: ' + game['Away Shots'] + '</span>';
html += '<span class="team-power-play">' + game['Away Power Play'] + '</span>';
html += '</div>';
html += '<span class="team-score">' + game['Away Score'] + '</span>';
html += '</div>';
html += '<div class="team-info">';
html += '<img src="' + game['Home Logo'] + '" alt="' + game['Home Team'] + ' Logo" class="team-logo">';
html += '<div class="team-info-column">';
html += '<span class="team-name">' + game['Home Team'] + '</span>';
html += '<span class="team-sog">SOG: ' + game['Home Shots'] + '</span>';
html += '<span class="team-power-play">' + game['Home Power Play'] + '</span>';
html += '</div>';
html += '<span class="team-score">' + game['Home Score'] + '</span>';
html += '</div>';
html += '<div class="game-info">';
if (game['Intermission']) {
html += '<div class="live-state-intermission">'
if (game['Period'] == 1 ) {
html += '1st Int';
}
if (game['Period'] == 2 ) {
html += '2nd Int';
}
if (game['Period'] == 3 ) {
html += '3rd Int';
}
html += '</div>';
html += '<div class="live-time-intermission">' + game['Time Remaining'] + '</div>';
} else {
html += '<div class="live-state">';
if (game['Period'] == 1 ) {
html += '1st';
}
else if (game['Period'] == 2 ) {
html += '2nd';
}
else if (game['Period'] == 3 ) {
html += '3rd';
}
else {
html += 'OT';
}
html += '</div>';
html += '<div class="live-time">' + game['Time Remaining'] + '</div>';
}
html += '</div>';
html += '<div class="game-info">';
html += '<strong>Game Score: </strong>' + game['Priority'];
html += '</div>';
html += '</div>';
} else if (state === 'PRE') {
html += '<div class="pre-state">' + game['Start Time'] + '</div>';
html += '<div class="team-info">';
html += '<img src="' + game['Away Logo'] + '" alt="' + game['Away Team'] + ' Logo" class="team-logo">';
html += '<span class="team-name">' + game['Away Team'] + '</span>';
html += '<span class="team-record">' + game['Away Record'] + '</span>';
html += '</div>';
html += '<div class="team-info">';
html += '<img src="' + game['Home Logo'] + '" alt="' + game['Home Team'] + ' Logo" class="team-logo">';
html += '<span class="team-name">' + game['Home Team'] + '</span>';
html += '<span class="team-record">' + game['Home Record'] + '</span>';
html += '</div>';
} else if (state === 'FINAL') {
html += '<div class="final-state">';
if (game['Last Period Type'] === 'REG') {
html += 'FINAL';
} else if (game['Last Period Type'] === 'OT') {
html += 'FINAL/OT';
} else {
html += 'FINAL/SO';
}
html += '</div>';
html += '<div class="team-info">';
html += '<img src="' + game['Away Logo'] + '" alt="' + game['Away Team'] + ' Logo" class="team-logo">';
html += '<div class="team-info-column">';
html += '<span class="team-name">' + game['Away Team'] + '</span>';
html += '<span class="team-sog">SOG: ' + game['Away Shots'] + '</span>';
html += '</div>';
html += '<span class="team-score">' + game['Away Score'] + '</span>';
html += '</div>';
html += '<div class="team-info">';
html += '<img src="' + game['Home Logo'] + '" alt="' + game['Home Team'] + ' Logo" class="team-logo">';
html += '<div class="team-info-column">';
html += '<span class="team-name">' + game['Home Team'] + '</span>';
html += '<span class="team-sog">SOG: ' + game['Home Shots'] + '</span>';
html += '</div>';
html += '<span class="team-score">' + game['Home Score'] + '</span>';
html += '</div>';
}
html += '</div>';
}
});
return html;
}
// Function to reload the scoreboard every 20 seconds
function autoRefresh() {
fetchScoreboardData();
setTimeout(autoRefresh, 5000); // 20 seconds
}
// Call the autoRefresh function when the page loads
window.onload = function() {
autoRefresh();
};
-244
View File
@@ -1,244 +0,0 @@
body {
background-color: #121212; /* Dark background color */
font-family: Arial, sans-serif; /* Use a common sans-serif font */
color: #fff; /* White text color */
}
h1 {
text-align: center;
margin-top: 20px;
color: #f2f2f2; /* Lighten the text color */
}
.scoreboard {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin-top: 20px;
}
.game-box {
background-color: #333; /* Dark background color for game boxes */
border-radius: 12px; /* Rounded corners */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Add a subtle shadow */
padding: 20px;
margin-bottom: 20px;
margin-left: 20px;
margin-right: 20px;
width: 300px;
position: relative; /* Position relative for absolute positioning */
}
.team-info {
display: flex;
align-items: center;
margin-bottom: 7px;
margin-top: 25px; /* Added margin-top */
}
.team-info-column {
display: flex;
flex-direction: column;
}
.team-logo {
width: 50px;
height: auto;
margin-right: 10px;
}
.team-name {
font-size: 18px;
font-weight: bold;
}
.team-score {
font-size: 25px;
font-weight: bold;
margin-left: auto;
}
.team-record {
font-size: 12px;
margin-left: auto;
font-weight: bold;
}
.team-sog {
font-size: 12px; /* Adjust font size as needed */
color: #ddd; /* Lighter text color */
}
.team-power-play {
font-size: 12px; /* Adjust font size as needed */
color: red; /* Set color to red */
margin-left: 10px; /* Add some margin for spacing */
}
.game-info {
margin-top: 12px;
color: #aaa; /* Lighten the text color */
text-align: center;
font-size: 14px;
}
.game-info strong {
margin-right: 5px;
}
.live-dot {
position: absolute;
top: 5px;
right: 5px;
width: 10px;
height: 10px;
background-color: red;
border-radius: 50%;
}
.pre-state {
position: absolute;
top: 10px;
left: 10px; /* Adjusted left position */
background-color: #444; /* Darker background color for pre state */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #fff; /* White text color for live state */
font-weight: bolder; /* Bold text for live state */
z-index: 1; /* Ensure the live state box is above other content */
}
.final-state {
position: absolute;
top: 10px;
left: 10px; /* Adjusted left position */
background-color: #444; /* Darker background color for final state */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #ddd; /* Lighter text color for final state */
z-index: 1; /* Ensure the final state box is above other content */
font-weight: bold;
}
.live-state {
position: absolute;
top: 10px;
left: 10px; /* Adjusted left position */
background-color: #0b6e31; /* Darker green background color for live state */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #fff; /* White text color for live state */
font-weight: bolder; /* Bold text for live state */
z-index: 1; /* Ensure the live state box is above other content */
}
.live-state-intermission {
position: absolute;
top: 10px;
left: 10px; /* Adjusted left position */
background-color: #444; /* Darker green background color for live state */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #fff; /* White text color for live state */
font-weight: bolder; /* Bold text for live state */
z-index: 1; /* Ensure the live state box is above other content */
}
.live-time {
position: absolute;
top: 10px;
left: 45px; /* Adjusted left position */
background-color: #444; /* Darker background color for time box */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #ddd; /* Lighter text color for time box */
z-index: 1; /* Ensure the time box is above other content */
}
.live-time-intermission {
position: absolute;
top: 10px;
left: 60px; /* Adjusted left position */
background-color: #444; /* Darker background color for time box */
padding: 5px;
border-radius: 5px;
font-size: 12px;
color: #ddd; /* Lighter text color for time box */
z-index: 1; /* Ensure the time box is above other content */
}
.live-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 20px;
}
.pre-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 20px;
}
.final-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 20px;
}
/* Existing CSS styles */
/* Add media query for smaller screens */
@media only screen and (max-width: 768px) {
.scoreboard {
flex-direction: column; /* Change direction to column for smaller screens */
align-items: center; /* Center align items */
}
.game-box {
width: 90%; /* Adjust width for better fit on smaller screens */
margin: 10px; /* Adjust margins */
}
.team-info {
align-items: center; /* Center align items */
margin-top: 26px; /* Adjust top margin */
margin-bottom: 5px; /* Adjust bottom margin */
}
.team-logo {
width: 36px; /* Adjust logo size */
height: 36px;
}
.team-name {
font-size: 16px; /* Decrease font size for better readability */
font-weight: bold;
}
.team-score {
font-size: 24px; /* Decrease font size for better readability */
font-weight: bold;
}
.game-info {
font-size: 12px; /* Decrease font size for better readability */
}
.live-state,
.live-time,
.pre-state,
.final-state {
font-size: 12px; /* Decrease font size for better readability */
}
}
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>NHL Scoreboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="static\styles.css">
</head>
<body>
<h1 id="live-games"></h1>
<div id="live-games-section"></div>
<h1 id="on-later"></h1>
<div id="pre-games-section"></div>
<h1 id="game-over"></h1>
<div id="final-games-section"></div>
<script src="/static/script.js"></script>
</body>
</html>
View File
+100
View File
@@ -0,0 +1,100 @@
import json
import sqlite3
import pytest
def make_game(
game_state="LIVE",
home_name="Maple Leafs",
away_name="Bruins",
home_score=2,
away_score=1,
period=3,
seconds_remaining=300,
in_intermission=False,
start_time_utc="2024-04-10T23:00:00Z",
home_record="40-25-10",
away_record="38-27-09",
game_type=2,
situation=None,
series_status=None,
):
clock = {
"timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}",
"secondsRemaining": seconds_remaining,
"running": game_state == "LIVE",
"inIntermission": in_intermission,
}
return {
"gameState": game_state,
"startTimeUTC": start_time_utc,
"periodDescriptor": {"number": period},
"clock": clock,
"homeTeam": {
"name": {"default": home_name},
"score": home_score,
"sog": 15,
"logo": "https://example.com/home.png",
"record": home_record,
},
"awayTeam": {
"name": {"default": away_name},
"score": away_score,
"sog": 12,
"logo": "https://example.com/away.png",
"record": away_record,
},
"gameOutcome": {"lastPeriodType": "REG"},
"gameType": game_type,
**({"situation": situation} if situation is not None else {}),
**({"seriesStatus": series_status} if series_status is not None else {}),
}
LIVE_GAME = make_game()
PRE_GAME = make_game(
game_state="FUT", home_score=0, away_score=0, period=0, seconds_remaining=1200
)
FINAL_GAME = make_game(game_state="OFF", period=3, seconds_remaining=0)
SAMPLE_SCOREBOARD = {"games": [LIVE_GAME, PRE_GAME, FINAL_GAME]}
@pytest.fixture()
def sample_scoreboard():
return SAMPLE_SCOREBOARD
@pytest.fixture()
def flask_client(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
data_dir.mkdir()
# Write sample scoreboard JSON
scoreboard_file = data_dir / "scoreboard_data.json"
scoreboard_file.write_text(json.dumps(SAMPLE_SCOREBOARD))
# Create minimal SQLite DB so get_team_standings doesn't crash
db_path = data_dir / "nhl_standings.db"
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE standings "
"(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER, "
"division_abbrev TEXT, conference_abbrev TEXT, games_played INTEGER, wildcard_sequence INTEGER)"
)
conn.commit()
conn.close()
# Patch module-level path constants so no reloads are needed
import app.routes as routes
import app.games as games
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(scoreboard_file))
monkeypatch.setattr(games, "DB_PATH", str(db_path))
from app import app as flask_app
flask_app.config["TESTING"] = True
with flask_app.test_client() as client:
yield client
+117
View File
@@ -0,0 +1,117 @@
import json
from datetime import datetime
from zoneinfo import ZoneInfo
import requests as req
from app.api import fetch_scores, refresh_scores
EASTERN = ZoneInfo("America/New_York")
class TestFetchScores:
def test_uses_now_url_during_evening(self, mocker):
"""7:30 PM ET → /score/now"""
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 10, 19, 30, tzinfo=EASTERN)
mock_get = mocker.patch("app.api.requests.get")
mock_get.return_value.json.return_value = {"games": []}
fetch_scores()
url = mock_get.call_args[0][0]
assert url == "https://api-web.nhle.com/v1/score/now"
def test_uses_now_url_after_midnight(self, mocker):
"""1:00 AM ET → /score/now (still considered game hours)"""
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 11, 1, 0, tzinfo=EASTERN)
mock_get = mocker.patch("app.api.requests.get")
mock_get.return_value.json.return_value = {"games": []}
fetch_scores()
url = mock_get.call_args[0][0]
assert url == "https://api-web.nhle.com/v1/score/now"
def test_uses_date_url_during_afternoon(self, mocker):
"""2:00 PM ET → date-based endpoint"""
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN)
mock_get = mocker.patch("app.api.requests.get")
mock_get.return_value.json.return_value = {"games": []}
fetch_scores()
url = mock_get.call_args[0][0]
assert "2024-04-10" in url
assert "now" not in url
def test_returns_json_on_success(self, mocker):
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN)
expected = {"games": [{"id": 1}]}
mock_get = mocker.patch("app.api.requests.get")
mock_get.return_value.json.return_value = expected
result = fetch_scores()
assert result == expected
def test_returns_none_on_request_exception(self, mocker):
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN)
mocker.patch(
"app.api.requests.get", side_effect=req.RequestException("timeout")
)
result = fetch_scores()
assert result is None
def test_returns_none_on_bad_status(self, mocker):
mock_dt = mocker.patch("app.api.datetime")
mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN)
mock_get = mocker.patch("app.api.requests.get")
mock_get.return_value.raise_for_status.side_effect = req.HTTPError("404")
result = fetch_scores()
assert result is None
class TestRefreshScores:
def test_writes_data_to_file(self, mocker, tmp_path):
data = {"games": [{"id": 1}]}
mocker.patch("app.api.fetch_scores", return_value=data)
score_file = tmp_path / "scoreboard_data.json"
mocker.patch("app.api.SCOREBOARD_DATA_FILE", str(score_file))
result = refresh_scores()
assert result == data
assert score_file.exists()
assert json.loads(score_file.read_text()) == data
def test_returns_none_when_fetch_fails(self, mocker):
mocker.patch("app.api.fetch_scores", return_value=None)
result = refresh_scores()
assert result is None
def test_does_not_write_file_when_fetch_fails(self, mocker, tmp_path):
mocker.patch("app.api.fetch_scores", return_value=None)
score_file = tmp_path / "scoreboard_data.json"
mocker.patch("app.api.SCOREBOARD_DATA_FILE", str(score_file))
refresh_scores()
assert not score_file.exists()
+771
View File
@@ -0,0 +1,771 @@
import app.games
from tests.conftest import make_game
from app.games import (
_get_man_advantage,
calculate_game_importance,
calculate_game_priority,
convert_game_state,
format_record,
get_comeback_bonus,
get_game_outcome,
get_period,
get_power_play_info,
get_start_time,
get_time_remaining,
parse_games,
utc_to_eastern,
)
class TestConvertGameState:
def test_off_maps_to_final(self):
assert convert_game_state("OFF") == "FINAL"
def test_crit_maps_to_live(self):
assert convert_game_state("CRIT") == "LIVE"
def test_fut_maps_to_pre(self):
assert convert_game_state("FUT") == "PRE"
def test_unknown_state_passes_through(self):
assert convert_game_state("LIVE") == "LIVE"
class TestProcessRecord:
def test_na_returns_na(self):
assert format_record("N/A") == "N/A"
def test_pads_single_digit_parts(self):
assert format_record("5-3-1") == "05-03-01"
def test_already_padded_unchanged(self):
assert format_record("40-25-10") == "40-25-10"
class TestProcessPeriod:
def test_pre_game_returns_zero(self):
game = make_game(game_state="PRE")
assert get_period(game) == 0
def test_fut_game_returns_zero(self):
game = make_game(game_state="FUT")
assert get_period(game) == 0
def test_final_game_returns_na(self):
game = make_game(game_state="OFF")
assert get_period(game) == "N/A"
def test_live_game_returns_period_number(self):
game = make_game(game_state="LIVE", period=2)
assert get_period(game) == 2
class TestProcessTimeRemaining:
def test_pre_game_returns_2000(self):
game = make_game(game_state="FUT")
assert get_time_remaining(game) == "20:00"
def test_final_game_returns_0000(self):
game = make_game(game_state="OFF")
assert get_time_remaining(game) == "00:00"
def test_live_game_returns_clock(self):
game = make_game(game_state="LIVE", seconds_remaining=305)
assert get_time_remaining(game) == "05:05"
def test_live_game_at_zero_returns_end(self):
game = make_game(game_state="LIVE", seconds_remaining=0)
assert get_time_remaining(game) == "END"
class TestProcessStartTime:
def test_pre_game_returns_est_time(self):
game = make_game(game_state="FUT", start_time_utc="2024-04-10T23:00:00Z")
result = get_start_time(game)
assert result == "7:00 PM"
def test_pre_game_strips_leading_zero(self):
game = make_game(game_state="FUT", start_time_utc="2024-04-10T22:00:00Z")
result = get_start_time(game)
assert not result.startswith("0")
def test_live_game_returns_na(self):
game = make_game(game_state="LIVE")
assert get_start_time(game) == "N/A"
class TestGetGameOutcome:
def test_final_game_returns_last_period_type(self):
game = make_game(game_state="OFF")
assert get_game_outcome(game, "FINAL") == "REG"
def test_live_game_returns_na(self):
game = make_game(game_state="LIVE")
assert get_game_outcome(game, "LIVE") == "N/A"
class TestUtcToEstTime:
def test_converts_utc_to_edt(self):
# April is EDT (UTC-4): 23:00 UTC → 07:00 PM EDT
result = utc_to_eastern("2024-04-10T23:00:00Z")
assert result == "07:00 PM"
def test_converts_utc_to_est(self):
# January is EST (UTC-5): 23:00 UTC → 06:00 PM EST
result = utc_to_eastern("2024-01-15T23:00:00Z")
assert result == "06:00 PM"
class TestParseGames:
def test_returns_empty_list_for_none(self):
assert parse_games(None) == []
def test_returns_empty_list_for_empty_dict(self):
assert parse_games({}) == []
class TestGetPowerPlayInfo:
def test_returns_empty_when_no_situation(self):
game = make_game()
assert get_power_play_info(game, "Maple Leafs") == ""
def test_returns_pp_info_for_away_team(self):
game = make_game(away_name="Bruins")
game["situation"] = {
"awayTeam": {"situationDescriptions": ["PP"]},
"homeTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
}
assert get_power_play_info(game, "Bruins") == "PP 1:30"
def test_returns_pp_info_for_home_team(self):
game = make_game(home_name="Maple Leafs", away_name="Bruins")
game["situation"] = {
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "0:45",
}
assert get_power_play_info(game, "Maple Leafs") == "PP 0:45"
class TestGetManAdvantage:
def test_standard_5v4(self):
# 1451: away 1G+4S=5, home 5S+1G=6 → advantage=1
assert _get_man_advantage({"situationCode": "1451"}) == 1
def test_5v3(self):
# 1351: away 1G+3S=4, home 5S+1G=6 → advantage=2
assert _get_man_advantage({"situationCode": "1351"}) == 2
def test_4v3(self):
# 1341: away 1G+3S=4, home 4S+1G=5 → advantage=1
assert _get_man_advantage({"situationCode": "1341"}) == 1
def test_even_strength(self):
# 1551: away 1G+5S=6, home 5S+1G=6 → advantage=0
assert _get_man_advantage({"situationCode": "1551"}) == 0
def test_missing_code_defaults_to_1(self):
assert _get_man_advantage({}) == 1
def test_invalid_code_defaults_to_1(self):
assert _get_man_advantage({"situationCode": "abc"}) == 1
class TestEmptyNetBonus:
def test_en_late_p3_adds_200(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
with_en = make_game(
game_state="LIVE",
period=3,
seconds_remaining=90,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200
def test_en_mid_p3_adds_150(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=3, seconds_remaining=300)
with_en = make_game(
game_state="LIVE",
period=3,
seconds_remaining=300,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "5:00",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150
def test_en_ot_adds_250(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
with_en = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["EN"]},
"awayTeam": {"situationDescriptions": []},
"timeRemaining": "10:00",
},
)
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250
def test_en_stacks_with_pp(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
with_both = make_game(
game_state="LIVE",
period=3,
seconds_remaining=90,
situation={
"homeTeam": {"situationDescriptions": ["PP", "EN"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
delta = calculate_game_priority(with_both) - calculate_game_priority(base)
# PP late P3 = 150, EN late P3 = 200, total = 350
assert delta == 350
class TestMultiManAdvantage:
def test_5v3_ot_pp_bonus_is_320(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
with_5v3 = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
"situationCode": "1351",
},
)
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320
def test_standard_5v4_unchanged(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
with_pp = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
"situationCode": "1451",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
class TestCalculateGamePriority:
def _live_game(
self,
period=3,
seconds_remaining=300,
home_score=2,
away_score=1,
in_intermission=False,
):
return make_game(
game_state="LIVE",
period=period,
seconds_remaining=seconds_remaining,
home_score=home_score,
away_score=away_score,
in_intermission=in_intermission,
)
def test_returns_zero_for_final(self):
game = make_game(game_state="OFF")
assert calculate_game_priority(game) == 0
def test_returns_zero_for_pre(self):
game = make_game(game_state="FUT")
assert calculate_game_priority(game) == 0
def test_intermission_returns_negative(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(in_intermission=True, seconds_remaining=0)
assert calculate_game_priority(game) < 0
def test_score_diff_greater_than_3(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(home_score=5, away_score=0)
result = calculate_game_priority(game)
assert isinstance(result, int)
def test_score_diff_greater_than_2(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(home_score=4, away_score=1)
result = calculate_game_priority(game)
assert isinstance(result, int)
def test_score_diff_greater_than_1(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(home_score=3, away_score=1)
result = calculate_game_priority(game)
assert isinstance(result, int)
def test_late_3rd_tied_bonus(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(
period=3, seconds_remaining=600, home_score=2, away_score=2
)
result = calculate_game_priority(game)
assert isinstance(result, int)
def test_final_6_minutes_tied_bonus(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = self._live_game(
period=3, seconds_remaining=300, home_score=2, away_score=2
)
result = calculate_game_priority(game)
assert isinstance(result, int)
def test_playoff_ot_escalates_per_period(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=600, game_type=3)
p5 = make_game(game_state="LIVE", period=5, seconds_remaining=600, game_type=3)
assert calculate_game_priority(p5) > calculate_game_priority(p4)
def test_shootout_ranks_below_late_ot(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
ot = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
# Sudden-death OT is more exciting than a skills competition
assert calculate_game_priority(ot) > calculate_game_priority(so)
def test_shootout_ranks_above_p2_blowout(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
blowout = make_game(
game_state="LIVE",
period=2,
seconds_remaining=600,
home_score=5,
away_score=1,
game_type=2,
)
assert calculate_game_priority(so) > calculate_game_priority(blowout)
def test_playoff_p4_higher_than_regular_season_p4(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
reg = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
playoff = make_game(
game_state="LIVE", period=4, seconds_remaining=600, game_type=3
)
assert calculate_game_priority(playoff) > calculate_game_priority(reg)
def test_closeness_bonus_tied_beats_one_goal(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
tied = self._live_game(home_score=2, away_score=2)
one_goal = self._live_game(home_score=2, away_score=1)
assert calculate_game_priority(tied) > calculate_game_priority(one_goal)
def test_5_4_same_priority_as_1_0(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
high_scoring = self._live_game(home_score=5, away_score=4)
low_scoring = self._live_game(home_score=1, away_score=0)
assert calculate_game_priority(high_scoring) == calculate_game_priority(
low_scoring
)
def test_pp_in_ot_adds_200(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
with_pp = make_game(
game_state="LIVE",
period=4,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
def test_pp_late_p3_adds_150(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = self._live_game(period=3, seconds_remaining=240)
with_pp = make_game(
game_state="LIVE",
period=3,
seconds_remaining=240,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150
def test_pp_mid_p3_adds_100(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = self._live_game(period=3, seconds_remaining=600)
with_pp = make_game(
game_state="LIVE",
period=3,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100
def test_pp_early_p3_adds_50(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = self._live_game(period=3, seconds_remaining=900)
with_pp = make_game(
game_state="LIVE",
period=3,
seconds_remaining=900,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50
def test_pp_p1_adds_30(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
base = self._live_game(period=1, seconds_remaining=600)
with_pp = make_game(
game_state="LIVE",
period=1,
seconds_remaining=600,
situation={
"homeTeam": {"situationDescriptions": ["PP"]},
"awayTeam": {"situationDescriptions": ["SH"]},
"timeRemaining": "1:30",
},
)
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30
def test_time_priority_increases_as_clock_runs(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
early = self._live_game(period=3, seconds_remaining=1100)
late = self._live_game(period=3, seconds_remaining=200)
assert calculate_game_priority(late) > calculate_game_priority(early)
class TestGetComebackBonus:
def setup_method(self):
app.games._score_cache.clear()
app.games._comeback_tracker.clear()
def test_returns_zero_on_first_call(self):
game = make_game(home_score=2, away_score=1)
assert get_comeback_bonus(game) == 0
def test_cache_populated_after_first_call(self):
game = make_game(home_score=2, away_score=1)
get_comeback_bonus(game)
assert ("Maple Leafs", "Bruins") in app.games._score_cache
def test_no_bonus_for_one_goal_swing(self):
# 1-goal swings are normal hockey, no bonus
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 1)
game = make_game(home_score=1, away_score=1, period=3)
assert get_comeback_bonus(game) == 0
def test_two_goal_recovery_in_p3(self):
# Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, period=3)
assert get_comeback_bonus(game) == 90 # 60*1.0 + 30
def test_three_goal_recovery_in_p3(self):
# Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 3)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
game = make_game(home_score=3, away_score=3, period=3)
assert get_comeback_bonus(game) == 150 # 120*1.0 + 30
def test_partial_recovery_in_p3(self):
# Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
game = make_game(home_score=2, away_score=3, period=3)
assert get_comeback_bonus(game) == 60 # 60*1.0
def test_bonus_persists_across_polls(self):
# Set up a 2-goal recovery, then call again — bonus stays
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, period=3)
first = get_comeback_bonus(game)
second = get_comeback_bonus(game)
assert first == second == 90
def test_period_multiplier_p1_lower(self):
# P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, period=1)
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
def test_ot_multiplier_higher(self):
# OT: base=60, period_mult=1.2, tie=30
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, period=4)
assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30)
def test_no_bonus_in_intermission(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
game = make_game(home_score=2, away_score=2, in_intermission=True)
assert get_comeback_bonus(game) == 0
def test_no_bonus_for_non_live_state(self):
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 3)
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
game = make_game(game_state="OFF", home_score=3, away_score=3)
assert get_comeback_bonus(game) == 0
def test_tracker_builds_max_deficit_over_time(self):
# Simulate progressive scoring: 0-1, 0-2, 1-2, 2-2
key = ("Maple Leafs", "Bruins")
get_comeback_bonus(make_game(home_score=0, away_score=1, period=1))
get_comeback_bonus(make_game(home_score=0, away_score=2, period=1))
get_comeback_bonus(make_game(home_score=1, away_score=2, period=2))
result = get_comeback_bonus(make_game(home_score=2, away_score=2, period=3))
assert app.games._comeback_tracker[key] == 2
assert result == 90 # 60*1.0 + 30
class TestCalculateGameImportance:
def _standings(
self,
league_seq=10,
l10_seq=10,
div="ATL",
conf="E",
gp=65,
wc=18,
):
return {
"league_sequence": league_seq,
"league_l10_sequence": l10_seq,
"division_abbrev": div,
"conference_abbrev": conf,
"games_played": gp,
"wildcard_sequence": wc,
}
def test_playoff_game_gets_fallback_importance(self):
game = make_game(game_type=3)
assert calculate_game_importance(game) == 100
def test_playoff_game7_cup_final_is_max(self):
game = make_game(
game_type=3,
series_status={"round": 4, "topSeedWins": 3, "bottomSeedWins": 3},
)
assert calculate_game_importance(game) == 200
def test_playoff_elimination_round1(self):
game = make_game(
game_type=3,
series_status={"round": 1, "topSeedWins": 3, "bottomSeedWins": 2},
)
assert calculate_game_importance(game) == 170
def test_playoff_game1_round1_lowest(self):
game = make_game(
game_type=3,
series_status={"round": 1, "topSeedWins": 0, "bottomSeedWins": 0},
)
assert calculate_game_importance(game) == 80
def test_playoff_later_rounds_more_important(self):
series = {"topSeedWins": 2, "bottomSeedWins": 2}
r1 = make_game(game_type=3, series_status={**series, "round": 1})
r3 = make_game(game_type=3, series_status={**series, "round": 3})
assert calculate_game_importance(r3) > calculate_game_importance(r1)
def test_returns_zero_for_final_game(self):
game = make_game(game_state="OFF")
assert calculate_game_importance(game) == 0
def test_near_zero_early_in_season(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=10, wc=18),
)
game = make_game(game_state="FUT")
assert calculate_game_importance(game) <= 10
def test_max_bonus_late_season_bubble_division_game(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
)
game = make_game(game_state="FUT")
assert calculate_game_importance(game) == 150
def test_same_division_beats_same_conference(self, mocker):
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
away_st_same_div = self._standings(gp=70, wc=18, div="ATL", conf="E")
away_st_diff_div = self._standings(gp=70, wc=18, div="MET", conf="E")
mocker.patch(
"app.games.get_team_standings",
side_effect=[home_st, away_st_same_div],
)
result_div = calculate_game_importance(make_game(game_state="FUT"))
mocker.patch(
"app.games.get_team_standings",
side_effect=[home_st, away_st_diff_div],
)
result_conf = calculate_game_importance(make_game(game_state="FUT"))
assert result_div > result_conf
def test_same_conference_beats_different_conference(self, mocker):
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
away_same_conf = self._standings(gp=70, wc=18, div="MET", conf="E")
away_diff_conf = self._standings(gp=70, wc=18, div="PAC", conf="W")
mocker.patch(
"app.games.get_team_standings",
side_effect=[home_st, away_same_conf],
)
result_same = calculate_game_importance(make_game(game_state="FUT"))
mocker.patch(
"app.games.get_team_standings",
side_effect=[home_st, away_diff_conf],
)
result_diff = calculate_game_importance(make_game(game_state="FUT"))
assert result_same > result_diff
def test_bubble_teams_beat_safely_in_teams(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=70, wc=18),
)
result_bubble = calculate_game_importance(make_game(game_state="FUT"))
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=70, wc=5),
)
result_safe = calculate_game_importance(make_game(game_state="FUT"))
assert result_bubble > result_safe
def test_eliminated_teams_have_lowest_relevance(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=70, wc=30),
)
assert calculate_game_importance(make_game(game_state="FUT")) < 30
def test_result_is_non_negative_int(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=0, wc=32),
)
result = calculate_game_importance(make_game(game_state="FUT"))
assert isinstance(result, int)
assert result >= 0
def test_result_never_exceeds_150(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
)
assert calculate_game_importance(make_game(game_state="FUT")) <= 150
+88
View File
@@ -0,0 +1,88 @@
import json
from tests.conftest import make_game
class TestIndexRoute:
def test_returns_200(self, flask_client):
response = flask_client.get("/")
assert response.status_code == 200
def test_returns_html(self, flask_client):
response = flask_client.get("/")
assert b"NHL Scoreboard" in response.data
class TestScoreboardRoute:
def test_returns_200(self, flask_client):
response = flask_client.get("/scoreboard")
assert response.status_code == 200
def test_returns_json_with_expected_keys(self, flask_client):
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "live_games" in data
assert "intermission_games" in data
assert "pre_games" in data
assert "final_games" in data
def test_intermission_games_separated_from_live(
self, flask_client, monkeypatch, tmp_path
):
import json as _json
import app.routes as routes
intermission_game = make_game(in_intermission=True)
live_game = make_game(home_name="Oilers", away_name="Flames")
scoreboard = {"games": [intermission_game, live_game]}
f = tmp_path / "scoreboard_data.json"
f.write_text(_json.dumps(scoreboard))
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(f))
data = _json.loads(flask_client.get("/scoreboard").data)
assert len(data["intermission_games"]) == 1
assert data["intermission_games"][0]["Intermission"] is True
assert len(data["live_games"]) == 1
assert data["live_games"][0]["Intermission"] is False
def test_live_games_have_required_fields(self, flask_client):
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
if data["live_games"]:
game = data["live_games"][0]
assert "Home Team" in game
assert "Away Team" in game
assert "Home Score" in game
assert "Away Score" in game
assert "Game State" in game
assert game["Game State"] == "LIVE"
def test_missing_file_returns_error(self, flask_client, monkeypatch):
import app.routes as routes
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", "/nonexistent/path.json")
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "error" in data
def test_invalid_json_returns_error(self, flask_client, monkeypatch, tmp_path):
import app.routes as routes
bad_file = tmp_path / "bad.json"
bad_file.write_text("not valid json {{{")
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(bad_file))
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "error" in data
def test_null_json_returns_error(self, flask_client, monkeypatch, tmp_path):
import app.routes as routes
null_file = tmp_path / "null.json"
null_file.write_text("null")
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(null_file))
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "error" in data
+58
View File
@@ -0,0 +1,58 @@
import pytest
from app.scheduler import start_scheduler
class TestStartScheduler:
def test_registers_standings_refresh_every_600_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 600 in intervals
def test_registers_score_refresh_every_10_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 10 in intervals
def test_runs_pending_on_each_tick(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
call_count = {"n": 0}
def sleep_twice(_):
call_count["n"] += 1
if call_count["n"] >= 2:
raise StopIteration
mocker.patch("app.scheduler.time.sleep", side_effect=sleep_twice)
with pytest.raises(StopIteration):
start_scheduler()
assert mock_schedule.run_pending.call_count >= 2
def test_continues_after_exception_in_run_pending(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
call_count = {"n": 0}
def raise_then_stop(_):
call_count["n"] += 1
if call_count["n"] >= 2:
raise StopIteration
mock_schedule.run_pending.side_effect = RuntimeError("boom")
mocker.patch("app.scheduler.time.sleep", side_effect=raise_then_stop)
with pytest.raises(StopIteration):
start_scheduler()
assert mock_schedule.run_pending.call_count >= 2
+310
View File
@@ -0,0 +1,310 @@
import sqlite3
import requests as req
from app.standings import (
create_standings_table,
fetch_standings,
insert_standings,
migrate_standings_table,
refresh_standings,
truncate_standings_table,
)
SAMPLE_API_RESPONSE = {
"standings": [
{
"teamCommonName": {"default": "Bruins"},
"leagueSequence": 1,
"leagueL10Sequence": 2,
"divisionAbbrev": "ATL",
"conferenceAbbrev": "E",
"gamesPlayed": 60,
"wildcardSequence": 5,
},
{
"teamCommonName": {"default": "Maple Leafs"},
"leagueSequence": 5,
"leagueL10Sequence": 3,
"divisionAbbrev": "ATL",
"conferenceAbbrev": "E",
"gamesPlayed": 61,
"wildcardSequence": 8,
},
]
}
class TestFetchStandings:
def test_returns_parsed_standings(self, mocker):
mock_get = mocker.patch("app.standings.requests.get")
mock_get.return_value.json.return_value = SAMPLE_API_RESPONSE
result = fetch_standings()
assert len(result) == 2
assert result[0] == {
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
assert result[1]["team_common_name"] == "Maple Leafs"
def test_returns_none_on_request_exception(self, mocker):
mocker.patch(
"app.standings.requests.get", side_effect=req.RequestException("err")
)
result = fetch_standings()
assert result is None
def test_returns_none_on_bad_status(self, mocker):
mock_get = mocker.patch("app.standings.requests.get")
mock_get.return_value.raise_for_status.side_effect = req.HTTPError("503")
result = fetch_standings()
assert result is None
def test_returns_empty_list_when_no_standings_key(self, mocker):
mock_get = mocker.patch("app.standings.requests.get")
mock_get.return_value.json.return_value = {}
result = fetch_standings()
assert result == []
class TestCreateStandingsTable:
def test_creates_table(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='standings'"
).fetchone()
assert row is not None
conn.close()
def test_is_idempotent(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
create_standings_table(conn) # should not raise
conn.close()
class TestTruncateStandingsTable:
def test_removes_all_rows(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
insert_standings(
conn,
[
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
],
)
truncate_standings_table(conn)
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
assert count == 0
conn.close()
class TestInsertStandings:
def test_inserts_all_rows(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
data = [
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
},
{
"team_common_name": "Maple Leafs",
"league_sequence": 5,
"league_l10_sequence": 3,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 61,
"wildcard_sequence": 8,
},
]
insert_standings(conn, data)
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
assert count == 2
conn.close()
def test_data_is_queryable_after_insert(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
insert_standings(
conn,
[
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
],
)
row = conn.execute(
"SELECT league_sequence FROM standings WHERE team_common_name = ?",
("Bruins",),
).fetchone()
assert row[0] == 1
conn.close()
class TestRefreshStandings:
def test_populates_db_from_api(self, mocker, tmp_path):
standings = [
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
]
mocker.patch("app.standings.fetch_standings", return_value=standings)
mocker.patch("app.standings.DB_PATH", str(tmp_path / "test.db"))
refresh_standings()
conn = sqlite3.connect(str(tmp_path / "test.db"))
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
conn.close()
assert count == 1
def test_clears_old_data_before_inserting(self, mocker, tmp_path):
db_path = str(tmp_path / "test.db")
mocker.patch("app.standings.DB_PATH", db_path)
first = [
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
]
mocker.patch("app.standings.fetch_standings", return_value=first)
refresh_standings()
second = [
{
"team_common_name": "Oilers",
"league_sequence": 3,
"league_l10_sequence": 1,
"division_abbrev": "PAC",
"conference_abbrev": "W",
"games_played": 62,
"wildcard_sequence": 3,
},
{
"team_common_name": "Jets",
"league_sequence": 4,
"league_l10_sequence": 2,
"division_abbrev": "CEN",
"conference_abbrev": "W",
"games_played": 61,
"wildcard_sequence": 4,
},
]
mocker.patch("app.standings.fetch_standings", return_value=second)
refresh_standings()
conn = sqlite3.connect(db_path)
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
conn.close()
assert count == 2
def test_does_not_insert_when_fetch_fails(self, mocker, tmp_path):
db_path = str(tmp_path / "test.db")
mocker.patch("app.standings.DB_PATH", db_path)
# Seed with existing data before the failed refresh
seed = [
{
"team_common_name": "Bruins",
"league_sequence": 1,
"league_l10_sequence": 2,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 60,
"wildcard_sequence": 5,
}
]
mocker.patch("app.standings.fetch_standings", return_value=seed)
refresh_standings()
# Now simulate a fetch failure — existing data must be preserved
mocker.patch("app.standings.fetch_standings", return_value=None)
refresh_standings()
conn = sqlite3.connect(db_path)
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
conn.close()
assert count == 1
class TestMigrateStandingsTable:
def test_adds_missing_columns_to_existing_table(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
conn.execute(
"CREATE TABLE standings "
"(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER)"
)
conn.commit()
migrate_standings_table(conn)
cols = [
row[1] for row in conn.execute("PRAGMA table_info(standings)").fetchall()
]
assert "division_abbrev" in cols
assert "conference_abbrev" in cols
assert "games_played" in cols
assert "wildcard_sequence" in cols
conn.close()
def test_is_idempotent(self, tmp_path):
conn = sqlite3.connect(str(tmp_path / "test.db"))
create_standings_table(conn)
migrate_standings_table(conn)
migrate_standings_table(conn) # must not raise
conn.close()
-64
View File
@@ -1,64 +0,0 @@
import sqlite3
import requests
def create_standings_table(conn):
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS standings (
team_common_name TEXT,
league_sequence INTEGER,
league_l10_sequence INTEGER
)
""")
conn.commit()
def truncate_standings_table(conn):
cursor = conn.cursor()
cursor.execute("DELETE FROM standings")
conn.commit()
def insert_standings_info(conn, standings_info):
cursor = conn.cursor()
for team in standings_info:
cursor.execute("""
INSERT INTO standings (team_common_name, league_sequence, league_l10_sequence)
VALUES (?, ?, ?)
""", (team["team_common_name"], team["league_sequence"], team["league_l10_sequence"]))
conn.commit()
def extract_standings_info():
url = "https://api-web.nhle.com/v1/standings/now"
response = requests.get(url)
if response.status_code == 200:
standings_data = response.json()
standings_info = []
for team in standings_data.get("standings", []):
team_info = {
"team_common_name": team["teamCommonName"]["default"],
"league_sequence": team["leagueSequence"],
"league_l10_sequence": team["leagueL10Sequence"]
}
standings_info.append(team_info)
return standings_info
else:
print("Error:", response.status_code)
return None
# Connect to SQLite database
conn = sqlite3.connect("nhl_standings.db")
# Create standings table if it doesn't exist
create_standings_table(conn)
# Truncate standings table before inserting new data
truncate_standings_table(conn)
# Extract standings info
standings_info = extract_standings_info()
# Insert standings info into the database
if standings_info:
insert_standings_info(conn, standings_info)
# Close database connection
conn.close()