Compare commits

...

63 Commits

Author SHA1 Message Date
josh 108b77ed39 feat: inline power play indicator as compact badge in card header
CI / Lint (push) Successful in 45s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 1m8s
Moves the PP team + countdown into the badges row next to period and
clock, freeing up a full line of vertical space on each live card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:05:38 -04:00
josh 61202b2a70 feat: sort scheduled games by start time instead of hype
Pre-game listings are more intuitive in chronological order than ranked
by pregame importance. LIVE and FINAL keep sorting by Priority desc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:05:35 -04:00
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
36 changed files with 3102 additions and 880 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 }}
+4 -3
View File
@@ -2,7 +2,8 @@
/nhle_standings_response.txt
/app/data/nhl_standings.db
/app/data/scoreboard_data.json
/__pycache__
/app/__pycache__
/app/scoreboard/__pycache__
nhl_standings.db
**/__pycache__
.venv/
.coverage
.pytest_cache/
+11 -14
View File
@@ -1,24 +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
# Create the directory for scoreboard data
RUN mkdir -p app/data
COPY . .
RUN mkdir -p $DATA_DIR
# Expose the Flask port
EXPOSE 2897
# Run the Flask application
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:2897/')" || exit 1
CMD ["python", "run.py"]
+30 -18
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
@@ -35,19 +54,12 @@ This web application displays live NHL game scores, team statistics, and game st
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)
+9 -1
View File
@@ -1,5 +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
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")
+547
View File
@@ -0,0 +1,547 @@
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),
"Start Time UTC": game.get("startTimeUTC", ""),
"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),
}
)
def _sort_key(g):
if g["Game State"] == "PRE":
# Earliest start first — ISO-8601 sorts correctly as a string
return (0, g["Start Time UTC"], 0)
# LIVE / FINAL — highest priority first
return (1, "", -g["Priority"])
return sorted(extracted_info, key=_sort_key)
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")
+50 -18
View File
@@ -1,32 +1,64 @@
from app import app
from flask import render_template, jsonify
from app.scoreboard.process_data import extract_game_info
import json
SCOREBOARD_DATA_FILE = 'app/data/scoreboard_data.json'
from flask import render_template, jsonify, send_from_directory
@app.route('/')
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')
return render_template("index.html")
@app.route("/scoreboard")
def get_scoreboard():
try:
with open(SCOREBOARD_DATA_FILE, 'r') as json_file:
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."})
return jsonify(
{"error": "Failed to retrieve scoreboard data. Invalid JSON format."}
)
if scoreboard_data:
live_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "LIVE"]
pre_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "PRE"]
final_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "FINAL"]
return jsonify({
"live_games": live_games,
"pre_games": pre_games,
"final_games": final_games
})
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)
-34
View File
@@ -1,34 +0,0 @@
import requests
from datetime import datetime
import json
SCOREBOARD_DATA_FILE = 'app/data/scoreboard_data.json'
def get_scoreboard_data():
now = datetime.now()
start_time_evening = now.replace(hour=23, minute=00, second=0, microsecond=0) # 7:00 PM EST
end_time_evening = now.replace(hour=8, minute=00, 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():
scoreboard_data = get_scoreboard_data()
if scoreboard_data:
with open(SCOREBOARD_DATA_FILE, 'w') as json_file:
json.dump(scoreboard_data, json_file)
return scoreboard_data
else:
return None
-147
View File
@@ -1,147 +0,0 @@
import sqlite3
from datetime import datetime, timedelta
def extract_game_info(scoreboard_data):
if not scoreboard_data:
return []
extracted_info = []
for game in scoreboard_data.get("games", []):
game_state = convert_game_state(game["gameState"])
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,
"Period": process_period(game),
"Time Remaining": process_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": calculate_game_priority(game),
"Start Time": process_start_time(game),
"Home Record": game["homeTeam"]["record"] if game["gameState"] in ["PRE", "FUT"] else "N/A",
"Away 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 convert_game_state(game_state):
state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"}
return state_mapping.get(game_state, game_state)
def process_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 process_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 process_start_time(game):
if game["gameState"] in ["PRE", "FUT"]:
utc_time = game["startTimeUTC"]
return utc_to_est_time(utc_time)
else:
return "N/A"
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 ""
def get_game_outcome(game, game_state):
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
def calculate_game_priority(game):
# Return 0 if game is in certain states
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
return 0
# Get period, time remaining, scores, and other relevant data
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) * 25
# Get standings 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_total = home_team_standings["league_sequence"] + home_team_standings["league_l10_sequence"]
away_total = away_team_standings["league_sequence"] + away_team_standings["league_l10_sequence"]
# Calculate the matchup adjustment factor
matchup_multiplier = {5: 1, 4: 1, 3: 1.25, 2: 1.50, 1: 2}.get(period)
matchup_adjustment = (home_total + away_total) * matchup_multiplier
# Calculate the base priority based on period
base_priority = {5: 650, 4: 600, 3: 300, 2: 200}.get(period, 100)
# Adjust base priority based on score difference
if score_difference > 3:
base_priority -= 500
elif score_difference > 2:
base_priority -= 350
elif score_difference > 1:
base_priority -= 100
# Adjust base priority based on certain conditions
if score_difference == 0 and period == 3 and time_remaining <= 600:
base_priority += 100
# Calculate time priority
time_multiplier = {4: 2, 3: 2, 2: 1.5}.get(period, 0.75)
time_priority = ((1200 - time_remaining) / 20) * time_multiplier
# Calculate the final priority
final_priority = int(base_priority + time_priority - matchup_adjustment + score_total)
# Pushes the games that are in intermission to the bottom, but retains their sort
if game["clock"]["inIntermission"]:
return (final_priority - 2000)
return final_priority
def get_team_standings(team_name):
conn = sqlite3.connect("app/data/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()
return {"league_sequence": result[0] if result else 0, "league_l10_sequence": result[1] if result else 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
return est_datetime.strftime("%#I:%M %p")
-13
View File
@@ -1,13 +0,0 @@
import schedule
import time
from app.scoreboard.update_nhl_standings_db import update_nhl_standings
from app.scoreboard.get_data import store_scoreboard_data
def schedule_tasks():
schedule.every(600).seconds.do(update_nhl_standings)
schedule.every(10).seconds.do(store_scoreboard_data)
while True:
schedule.run_pending()
time.sleep(1)
-65
View File
@@ -1,65 +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
def update_nhl_standings():
# Connect to SQLite database
conn = sqlite3.connect("app/data/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()
+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"
}
]
}
+220 -219
View File
@@ -1,243 +1,244 @@
// 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();
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 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');
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 },
];
if (liveGamesSection) {
var liveGamesExist = data && data.live_games && data.live_games.length > 0;
if (liveGamesExist) {
if (!document.getElementById('live-games')) {
var targetElement = document.getElementById('live-games-section');
var newElement = document.createElement('h1');
newElement.setAttribute('id', 'live-games');
newElement.innerText = 'Live Games';
targetElement.parentNode.insertBefore(newElement, targetElement);
}
liveGamesSection.innerHTML = generateGameBoxes(data.live_games, 'LIVE');
} else {
var liveGamesElement = document.getElementById('live-games');
if (liveGamesElement) {
liveGamesElement.remove();
}
liveGamesSection.innerHTML = '';
}
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);
}
if (preGamesSection) {
var preGamesExist = data && data.pre_games && data.pre_games.length > 0;
if (preGamesExist) {
if (!document.getElementById('on-later')) {
var targetElement = document.getElementById('pre-games-section');
var newElement = document.createElement('h1');
newElement.setAttribute('id', 'on-later');
newElement.innerText = 'Scheduled Games';
targetElement.parentNode.insertBefore(newElement, targetElement);
}
preGamesSection.innerHTML = generateGameBoxes(data.pre_games, 'PRE');
} else {
var onLaterElement = document.getElementById('on-later');
if (onLaterElement) {
onLaterElement.remove();
}
preGamesSection.innerHTML = '';
}
}
if (finalGamesSection) {
var finalGamesExist = data && data.final_games && data.final_games.length > 0;
// Check if final games exist
if (finalGamesExist) {
// Create or update "Game Over" heading
if (!document.getElementById('game-over')) {
var targetElement = document.getElementById('final-games-section');
var newElement = document.createElement('h1');
newElement.setAttribute('id', 'game-over');
newElement.innerText = 'Game Over';
targetElement.parentNode.insertBefore(newElement, targetElement);
}
// Update final games section with generated game boxes
finalGamesSection.innerHTML = generateGameBoxes(data.final_games, 'FINAL');
} else {
// Remove "Game Over" heading if it exists and clear final games section
var gameOverElement = document.getElementById('game-over');
if (gameOverElement) {
gameOverElement.remove();
}
finalGamesSection.innerHTML = '';
}
}
updateGauge()
updateGauges();
}
function updateGauge() {
document.querySelectorAll('.gauge').forEach(function(gauge) {
// Get the score value from the data-score attribute
var score = parseInt(gauge.getAttribute('data-score'));
// ── Renderers ────────────────────────────────────────
// Clamp the score value between 0 and 700
score = Math.min(700, Math.max(0, score));
function renderLiveGame(game) {
const intermission = game['Intermission'];
const period = game['Period'];
const time = game['Time Remaining'];
const running = game['Time Running'];
// Calculate the gauge width as a percentage
var gaugeWidth = (score / 700) * 100;
const periodLabel = intermission
? `<span class="badge">${intermissionLabel(period)}</span>`
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
// Set the width of the gauge
gauge.style.width = gaugeWidth + '%';
const dot = running ? `<span class="live-dot"></span>` : '';
if (score <=350) {
gauge.style.backgroundColor = '#4A90E2'
} else if (score <= 560) {
gauge.style.backgroundColor = '#FF4500'
} else {
gauge.style.backgroundColor = '#FF0033'
// 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>
${ppBadge(game)}
</div>
${dot}
</div>
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${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 ppBadge(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 `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
}
// ── 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 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>';
if (!game['Intermission']) {
html += '<div class="hype-meter-label">';
html += '<strong>Hype Meter</strong>';
html += '</div>';
html += '<div class="game-score-gauge">';
html += '<div class="gauge" data-score="' + game['Priority'] + '"></div>';
html += '</div>';
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>';
}
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));
});
return html;
}
// Function to reload the scoreboard every 20 seconds
// ── 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); // 20 seconds
setTimeout(autoRefresh, 5000);
}
// Call the autoRefresh function when the page loads
window.onload = function() {
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);
});
}
});
+277 -304
View File
@@ -1,381 +1,354 @@
body {
background-color: #121212;
font-family: Arial, sans-serif;
color: #fff;
: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;
}
h1 {
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;
margin-top: 0.8%;
margin-bottom: 1.5%;
color: #f2f2f2;
font-size: 2.2em;
}
.scoreboard {
.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;
justify-content: space-around;
margin-top: 20px;
gap: var(--gap);
justify-content: center;
}
/* ── Game Card ──────────────────────────────────── */
.game-box {
background-color: #333;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 1%;
width: 16%;
max-width: 350px;
position: relative;
margin-left: 1%;
margin-right: 1%;
margin-bottom: 1.15%;
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;
}
.team-info {
.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;
margin-bottom: 2%;
margin-top: 9%;
justify-content: space-between;
margin-bottom: 0.5rem;
min-height: 1.25rem;
}
.team-info-column {
.badges {
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: center;
flex-wrap: wrap;
}
.team-logo {
width: 18%;
height: auto;
margin-right: 2.25%;
.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;
}
.team-name {
font-size: 1rem;
font-weight: bold;
.badge-live {
background: var(--green-bg);
color: var(--green-text);
}
/* Add a media query for screens between 769px and 900px */
@media only screen and (max-width: 950px) and (min-width: 769px) {
.team-name {
font-size: 0.55rem; /* Adjusted font size for screens between 769px and 900px */
}
}
.team-score {
font-size: 1.35rem;
font-weight: bold;
margin-left: auto;
}
.team-record {
font-size: 0.8rem;
font-weight: bold;
margin-left: auto;
}
/* Add a media query for screens between 769px and 900px */
@media only screen and (max-width: 950px) and (min-width: 769px) {
.team-record {
font-size: 0.45rem; /* Adjusted font size for screens between 769px and 900px */
}
}
.team-sog {
font-size: 0.75rem;
color: #ddd;
}
.team-power-play {
font-size: 12px;
color: red;
margin-left: 10px;
}
.game-info {
margin-top: 12px;
color: #aaa;
text-align: center;
font-size: 80%;
}
.hype-meter-label {
margin-top: 3%;
color: #aaa;
text-align: center;
font-size: 80%;
margin-bottom: 3%;
.badge-muted {
color: var(--text-muted);
}
.live-dot {
position: absolute;
top: 5px;
right: 5px;
width: 10px;
height: 10px;
background-color: red;
width: 7px;
height: 7px;
background: var(--red);
border-radius: 50%;
flex-shrink: 0;
animation: pulse 1.8s ease-in-out infinite;
}
.pre-state {
position: absolute;
top: 5%;
left: 3%;
background-color: #444;
padding: 1.5%;
border-radius: 5px;
font-size: 0.75rem;
color: #fff;
font-weight: bolder;
z-index: 1;
width: auto;
height: 7%;
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Team Rows ──────────────────────────────────── */
.team-row {
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 0.625rem;
padding: 0.55rem 0;
}
/* Add a media query for screens between 769px and 900px */
@media only screen and (max-width: 950px) and (min-width: 769px) {
.pre-state {
font-size: 0.55rem; /* Adjusted font size for screens between 769px and 900px */
}
.team-row + .team-row {
border-top: 1px solid var(--card-border);
}
.final-state {
position: absolute;
top: 5%;
left: 3%;
background-color: #444;
padding: 1.5%;
border-radius: 5px;
font-size: 0.7rem;
color: #ddd;
z-index: 1;
font-weight: bold;
width: auto;
height: 7%;
display: flex;
justify-content: space-evenly;
align-items: center;
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
.live-state {
position: absolute;
top: 4%;
left: 4%;
background-color: #0b6e31;
padding: 1.5%;
border-radius: 5px;
.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: #fff;
font-weight: bolder;
z-index: 1;
width: 7%;
height: 7%;
display: flex;
justify-content: space-evenly;
align-items: center;
color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
white-space: nowrap;
}
.live-time {
position: absolute;
top: 4%;
left: 15%;
background-color: #444;
padding: 1.5%;
border-radius: 5px;
font-size: 0.75rem;
color: #ddd;
z-index: 1;
display: flex;
justify-content: space-evenly;
align-items: center;
width: 10%;
height: 7%;
/* ── Power Play Badge (inline in card header) ─── */
.badge-pp {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
border: 1px solid rgba(239, 68, 68, 0.35);
font-variant-numeric: tabular-nums;
}
.live-state-intermission {
position: absolute;
top: 4%;
left: 4%;
background-color: #444;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
color: #fff;
font-weight: bolder;
z-index: 1;
width: 11%;
height: 8.5%;
display: flex;
justify-content: space-evenly;
align-items: center;
/* ── Hype Meter ─────────────────────────────────── */
.hype-meter {
margin-top: 0.75rem;
}
.live-time-intermission {
position: absolute;
top: 4%;
left: 19%;
background-color: #444;
padding: 1.5%;
border-radius: 5px;
font-size: 0.75rem;
color: #ddd;
z-index: 1;
width: 10%;
height: 8.5%;
display: flex;
justify-content: space-evenly;
align-items: center;
.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;
}
#live-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
}
#pre-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
}
#final-games-section {
display: flex;
align-items: start;
flex-wrap: wrap;
justify-content: flex-start;
}
/* Add styles for the game score gauge */
.game-score-gauge {
height: 1%;
background-color: #ccc;
border-radius: 5px;
overflow: hidden;
.gauge-track {
height: 4px;
background: var(--badge-bg);
border-radius: 99px;
overflow: hidden;
}
.gauge {
height: 10px; /* Adjust height as needed */
/*#8A2BE2*/
/*#6699CC*/
height: 100%;
border-radius: 99px;
width: 0;
transition: width 0.5s ease;
}
/* 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 */
/* ── 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 {
width: 90%;
padding: 4%;
margin-bottom: 4%;
margin-left: 2%;
margin-right: 2%;
max-width: 1000px;
}
.team-info {
align-items: center;
margin-top: 10%;
margin-bottom: 2%;
padding: 1.125rem 1.125rem 1rem;
}
.team-logo {
width: 12%;
height: auto;
width: 48px;
height: 48px;
}
.badge {
font-size: 0.75rem;
}
.team-name {
font-size: 100%;
font-weight: bold;
font-size: 0.95rem;
}
.team-score {
font-size: 140%;
font-weight: bold;
font-size: 1.9rem;
}
.team-sog {
font-size: 70%;
.hype-label {
font-size: 0.7rem;
}
}
@media (min-width: 1400px) {
:root {
--card-w: 400px;
--gap: 1.5rem;
}
.game-info {
font-size: 90%;
main {
padding: 1.25rem 2.5rem 3rem;
}
.live-state {
top: 5%;
left: 3.5%;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
width: 5.5%;
height: 7.2%;
}
.live-time {
top: 5%;
left: 13%;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
display: flex;
justify-content: space-evenly;
align-items: center;
width: 7%;
height: 7.2%;
}
.live-state-intermission {
top: 5%;
left: 3.5%;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
width: 11%;
height: 7.5%;
display: flex;
justify-content: space-evenly;
align-items: center;
.header-title {
font-size: 2.8rem;
}
.live-time-intermission {
top: 5%;
left: 18.5%;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
width: 8%;
height: 7.5%;
display: flex;
justify-content: space-evenly;
align-items: center;
.section-heading {
font-size: 0.95rem;
}
.final-state {
top: 5%;
left: 3.5%;
padding: 1.5%;
border-radius: 5px;
font-size: 72%;
width: auto;
height: 7.5%;
.game-box {
padding: 1.25rem 1.25rem 1.125rem;
}
.pre-state {
top: 5%;
left: 3.5%;
padding: 1.5%;
border-radius: 5px;
font-size: 80%;
.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;
});
})
);
});
+29 -4
View File
@@ -3,12 +3,37 @@
<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">
<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>
<div id="live-games-section"></div>
<div id="pre-games-section"></div>
<div id="final-games-section"></div>
<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
+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
+13 -11
View File
@@ -1,13 +1,15 @@
from app import app
from waitress import serve
import threading
from app.scoreboard.tasks import schedule_tasks
from app.scoreboard.get_data import store_scoreboard_data
from app.scoreboard.update_nhl_standings_db import update_nhl_standings
if __name__ == '__main__':
store_scoreboard_data()
update_nhl_standings()
threading.Thread(target=schedule_tasks).start()
serve(app, host="0.0.0.0", port=2897)
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)
+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()
+889
View File
@@ -0,0 +1,889 @@
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({}) == []
def test_pre_games_sorted_by_start_time_ascending(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
},
)
late = make_game(
game_state="FUT",
home_name="Rangers",
away_name="Devils",
start_time_utc="2024-04-10T22:00:00Z",
)
early = make_game(
game_state="FUT",
home_name="Bruins",
away_name="Canadiens",
start_time_utc="2024-04-10T19:00:00Z",
)
next_day = make_game(
game_state="FUT",
home_name="Kings",
away_name="Ducks",
start_time_utc="2024-04-11T00:30:00Z",
)
result = parse_games({"games": [late, next_day, early]})
pre_games = [g for g in result if g["Game State"] == "PRE"]
names = [g["Home Team"] for g in pre_games]
assert names == ["Bruins", "Rangers", "Kings"]
def test_live_games_still_sorted_by_priority_descending(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
},
)
# Late P3 tied — high priority
high = make_game(
game_state="LIVE",
home_name="Rangers",
away_name="Devils",
home_score=2,
away_score=2,
period=3,
seconds_remaining=60,
)
# Early P1 blowout — low priority
low = make_game(
game_state="LIVE",
home_name="Bruins",
away_name="Canadiens",
home_score=5,
away_score=0,
period=1,
seconds_remaining=900,
)
result = parse_games({"games": [low, high]})
live_games = [g for g in result if g["Game State"] == "LIVE"]
assert live_games[0]["Home Team"] == "Rangers"
assert live_games[1]["Home Team"] == "Bruins"
def test_pre_games_ignore_priority_even_if_nonzero(self, mocker):
# Give one team standings that maximize importance, another that minimize it
def fake_standings(name):
if name == "Bruins":
return {
"league_sequence": 1,
"league_l10_sequence": 1,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 80,
"wildcard_sequence": 18,
}
return {
"league_sequence": 32,
"league_l10_sequence": 32,
"division_abbrev": "PAC",
"conference_abbrev": "W",
"games_played": 10,
"wildcard_sequence": 30,
}
mocker.patch("app.games.get_team_standings", side_effect=fake_standings)
# Bruins game starts later but will have higher importance
high_hype_late = make_game(
game_state="FUT",
home_name="Bruins",
away_name="Maple Leafs",
start_time_utc="2024-04-10T23:00:00Z",
)
low_hype_early = make_game(
game_state="FUT",
home_name="Kings",
away_name="Ducks",
start_time_utc="2024-04-10T19:00:00Z",
)
result = parse_games({"games": [high_hype_late, low_hype_early]})
pre_games = [g for g in result if g["Game State"] == "PRE"]
# Lower-hype-but-earlier game must still appear first
assert pre_games[0]["Home Team"] == "Kings"
assert pre_games[1]["Home Team"] == "Bruins"
assert pre_games[1]["Priority"] > pre_games[0]["Priority"]
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()