Compare commits
49 Commits
v4.1.5
...
7784eaf9ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 7784eaf9ce | |||
| 6c098850f5 | |||
| f652743333 | |||
| 429c42e7b0 | |||
| 8e1c455ded | |||
| 9edc9914a3 | |||
| cf0dec3513 | |||
| 58c31d6766 | |||
| c9f5c7c929 | |||
| 3d77c7cd5a | |||
| 2f2b3f2d7e | |||
| bf39bb6bd5 | |||
| 47a8c34215 | |||
| 8945b99782 | |||
| 257e2151c8 | |||
| 96529c4705 | |||
| e2d2c7dd97 | |||
| 62afc1001e | |||
| 3169d1a1ff | |||
| 56feb0a5f2 | |||
| ed05d6adfc | |||
| 889f429dc6 | |||
| 1394b21fb3 | |||
| cb712245c2 | |||
| 869a7a91b7 | |||
| 66fff68e6a | |||
| bada8c0b7b | |||
| 9ad563ed3f | |||
| def491a4d4 | |||
| dd5ac945bd | |||
| a4dc7dff52 | |||
| da277e41a4 | |||
| d1d711828c | |||
| 10d7cb9b02 | |||
| 73af434851 | |||
| f911d5d59d | |||
| 1d2901035e | |||
| 72ad9568cd | |||
| 3994943757 | |||
| b10736d43c | |||
| 8913b40a8c | |||
| daabae1e49 | |||
| 53a0fc7993 | |||
| a1352869ad | |||
| f059d4228b | |||
| c8f535ee48 | |||
| 65369896cc | |||
| 7e41cf4781 | |||
| 20ffd05df1 |
@@ -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 }}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "app/data")
|
||||
PORT = int(os.environ.get("PORT", 2897))
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
SCOREBOARD_DATA_FILE = os.path.join(DATA_DIR, "scoreboard_data.json")
|
||||
DB_PATH = os.path.join(DATA_DIR, "nhl_standings.db")
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import DB_PATH
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps (home_team_name, away_team_name) -> (home_score, away_score)
|
||||
_score_cache: dict[tuple[str, str], tuple[int, int]] = {}
|
||||
|
||||
# Maps (home_team_name, away_team_name) -> max score differential seen
|
||||
_comeback_tracker: dict[tuple[str, str], int] = {}
|
||||
|
||||
|
||||
def format_record(record):
|
||||
if record == "N/A":
|
||||
return "N/A"
|
||||
else:
|
||||
parts = record.split("-")
|
||||
formatted_parts = [part.zfill(2) for part in parts]
|
||||
return "-".join(formatted_parts)
|
||||
|
||||
|
||||
def parse_games(scoreboard_data):
|
||||
if not scoreboard_data:
|
||||
return []
|
||||
|
||||
extracted_info = []
|
||||
for game in scoreboard_data.get("games", []):
|
||||
game_state = convert_game_state(game["gameState"])
|
||||
priority_comps = _priority_components(game)
|
||||
comeback = get_comeback_bonus(game)
|
||||
importance_comps = _importance_components(game)
|
||||
total_priority = priority_comps["total"] + comeback + importance_comps["total"]
|
||||
extracted_info.append(
|
||||
{
|
||||
"Home Team": game["homeTeam"]["name"]["default"],
|
||||
"Home Score": game["homeTeam"]["score"]
|
||||
if game_state != "PRE"
|
||||
else "N/A",
|
||||
"Away Team": game["awayTeam"]["name"]["default"],
|
||||
"Away Score": game["awayTeam"]["score"]
|
||||
if game_state != "PRE"
|
||||
else "N/A",
|
||||
"Home Logo": game["homeTeam"]["logo"],
|
||||
"Away Logo": game["awayTeam"]["logo"],
|
||||
"Game State": game_state,
|
||||
"Game Type": game.get("gameType", 2),
|
||||
"Period": get_period(game),
|
||||
"Time Remaining": get_time_remaining(game),
|
||||
"Time Running": game["clock"]["running"]
|
||||
if game_state == "LIVE"
|
||||
else "N/A",
|
||||
"Intermission": game["clock"]["inIntermission"]
|
||||
if game_state == "LIVE"
|
||||
else "N/A",
|
||||
"Priority": total_priority,
|
||||
"Hype Breakdown": {
|
||||
"base": priority_comps["base"],
|
||||
"time": priority_comps["time"],
|
||||
"matchup_bonus": priority_comps["matchup_bonus"],
|
||||
"closeness": priority_comps["closeness"],
|
||||
"power_play": priority_comps["power_play"],
|
||||
"empty_net": priority_comps["empty_net"],
|
||||
"comeback": comeback,
|
||||
"importance": importance_comps["total"],
|
||||
"importance_season_weight": importance_comps["season_weight"],
|
||||
"importance_playoff_relevance": importance_comps[
|
||||
"playoff_relevance"
|
||||
],
|
||||
"importance_rivalry": importance_comps["rivalry"],
|
||||
"total": total_priority,
|
||||
},
|
||||
"Start Time": get_start_time(game),
|
||||
"Home Record": format_record(game["homeTeam"]["record"])
|
||||
if game["gameState"] in ["PRE", "FUT"]
|
||||
else "N/A",
|
||||
"Away Record": format_record(game["awayTeam"]["record"])
|
||||
if game["gameState"] in ["PRE", "FUT"]
|
||||
else "N/A",
|
||||
"Home Shots": game["homeTeam"]["sog"]
|
||||
if game["gameState"] not in ["PRE", "FUT"]
|
||||
else 0,
|
||||
"Away Shots": game["awayTeam"]["sog"]
|
||||
if game["gameState"] not in ["PRE", "FUT"]
|
||||
else 0,
|
||||
"Home Power Play": get_power_play_info(
|
||||
game, game["homeTeam"]["name"]["default"]
|
||||
),
|
||||
"Away Power Play": get_power_play_info(
|
||||
game, game["awayTeam"]["name"]["default"]
|
||||
),
|
||||
"Last Period Type": get_game_outcome(game, game_state),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort games based on priority
|
||||
return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True)
|
||||
|
||||
|
||||
def get_comeback_bonus(game):
|
||||
"""Persistent comeback bonus that scales with deficit recovered.
|
||||
|
||||
Tracks the maximum score differential seen in the game. A recovery of 2+
|
||||
goals earns a sustained bonus that persists as long as the game remains
|
||||
close. One-goal swings are normal hockey and earn no bonus.
|
||||
"""
|
||||
if game["gameState"] not in ("LIVE", "CRIT"):
|
||||
return 0
|
||||
if game["clock"]["inIntermission"]:
|
||||
return 0
|
||||
|
||||
home_name = game["homeTeam"]["name"]["default"]
|
||||
away_name = game["awayTeam"]["name"]["default"]
|
||||
key = (home_name, away_name)
|
||||
|
||||
home_score = game["homeTeam"]["score"]
|
||||
away_score = game["awayTeam"]["score"]
|
||||
current_diff = abs(home_score - away_score)
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
|
||||
tracker_max = _comeback_tracker.get(key, 0)
|
||||
if key in _score_cache:
|
||||
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
|
||||
tracker_max = max(tracker_max, prev_diff)
|
||||
_comeback_tracker[key] = tracker_max
|
||||
_score_cache[key] = (home_score, away_score)
|
||||
|
||||
recovery = tracker_max - current_diff
|
||||
if recovery < 2 or tracker_max < 2:
|
||||
return 0
|
||||
|
||||
base = {2: 60, 3: 120}.get(recovery, 160)
|
||||
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
|
||||
tie_bonus = 30 if current_diff == 0 else 0
|
||||
|
||||
return int(base * period_mult + tie_bonus)
|
||||
|
||||
|
||||
def convert_game_state(game_state):
|
||||
state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"}
|
||||
return state_mapping.get(game_state, game_state)
|
||||
|
||||
|
||||
def get_period(game):
|
||||
if game["gameState"] in ["PRE", "FUT"]:
|
||||
return 0
|
||||
elif game["gameState"] in ["FINAL", "OFF"]:
|
||||
return "N/A"
|
||||
else:
|
||||
return game["periodDescriptor"]["number"]
|
||||
|
||||
|
||||
def get_time_remaining(game):
|
||||
if game["gameState"] in ["PRE", "FUT"]:
|
||||
return "20:00"
|
||||
elif game["gameState"] in ["FINAL", "OFF"]:
|
||||
return "00:00"
|
||||
else:
|
||||
time_remaining = game["clock"]["timeRemaining"]
|
||||
return "END" if time_remaining == "00:00" else time_remaining
|
||||
|
||||
|
||||
def get_start_time(game):
|
||||
if game["gameState"] in ["PRE", "FUT"]:
|
||||
utc_time = game["startTimeUTC"]
|
||||
est_time = utc_to_eastern(utc_time)
|
||||
return est_time.lstrip("0")
|
||||
else:
|
||||
return "N/A"
|
||||
|
||||
|
||||
def get_power_play_info(game, team_name):
|
||||
situation = game.get("situation", {})
|
||||
if not situation:
|
||||
return ""
|
||||
time_remaining = situation.get("timeRemaining", "")
|
||||
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
|
||||
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
|
||||
if "PP" in home_descs and game["homeTeam"]["name"]["default"] == team_name:
|
||||
return f"PP {time_remaining}"
|
||||
if "PP" in away_descs and game["awayTeam"]["name"]["default"] == team_name:
|
||||
return f"PP {time_remaining}"
|
||||
return ""
|
||||
|
||||
|
||||
def get_game_outcome(game, game_state):
|
||||
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
|
||||
|
||||
|
||||
def _get_man_advantage(situation):
|
||||
"""Parse situationCode for player count difference.
|
||||
Format: [away_goalie][away_skaters][home_skaters][home_goalie]."""
|
||||
code = situation.get("situationCode", "")
|
||||
if len(code) != 4 or not code.isdigit():
|
||||
return 1
|
||||
away_total = int(code[0]) + int(code[1])
|
||||
home_total = int(code[2]) + int(code[3])
|
||||
return abs(home_total - away_total)
|
||||
|
||||
|
||||
def _priority_components(game):
|
||||
"""Return a dict of all priority components plus the final total."""
|
||||
_zero = {
|
||||
"base": 0,
|
||||
"time": 0,
|
||||
"matchup_bonus": 0,
|
||||
"closeness": 0,
|
||||
"power_play": 0,
|
||||
"empty_net": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
|
||||
return _zero
|
||||
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
|
||||
home_score = game["homeTeam"]["score"]
|
||||
away_score = game["awayTeam"]["score"]
|
||||
score_difference = abs(home_score - away_score)
|
||||
is_playoff = game.get("gameType", 2) == 3
|
||||
|
||||
# ── 1. Base priority by period ────────────────────────────────────────
|
||||
if is_playoff:
|
||||
base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150)
|
||||
else:
|
||||
base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150)
|
||||
|
||||
# ── 2. Period length for time calculations ────────────────────────────
|
||||
period_length = (1200 if is_playoff else 300) if period >= 4 else 1200
|
||||
|
||||
# ── 3. Standings-quality matchup bonus ───────────────────────────────
|
||||
# Invert rank so that #1 (best) contributes the most quality points.
|
||||
# league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32.
|
||||
home_standings = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||
away_standings = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||
home_quality = (33 - home_standings["league_sequence"]) + (
|
||||
33 - home_standings["league_l10_sequence"]
|
||||
)
|
||||
away_quality = (33 - away_standings["league_sequence"]) + (
|
||||
33 - away_standings["league_l10_sequence"]
|
||||
)
|
||||
# Higher period = matchup matters less (any OT is exciting regardless of teams)
|
||||
matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0)
|
||||
matchup_bonus = (home_quality + away_quality) * matchup_multiplier
|
||||
|
||||
# ── Shootout: flat priority, no time component (rounds, not clock) ───
|
||||
if period == 5 and not is_playoff:
|
||||
so_base = 550
|
||||
so_closeness = 80
|
||||
so_matchup = (home_quality + away_quality) * 1.0
|
||||
so_total = int(so_base + so_closeness + so_matchup)
|
||||
return {
|
||||
"base": so_base,
|
||||
"time": 0,
|
||||
"matchup_bonus": int(so_matchup),
|
||||
"closeness": so_closeness,
|
||||
"power_play": 0,
|
||||
"empty_net": 0,
|
||||
"total": so_total,
|
||||
}
|
||||
|
||||
# ── 4. Score-differential penalty (period-aware) ───────────────────────
|
||||
score_differential_adjustment = 0
|
||||
if period <= 2:
|
||||
adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350}
|
||||
score_differential_adjustment = adj.get(
|
||||
score_difference, 350 + (score_difference - 4) * 100
|
||||
)
|
||||
elif period == 3:
|
||||
mins_left = time_remaining / 60
|
||||
if mins_left > 10:
|
||||
adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400}
|
||||
elif mins_left > 5:
|
||||
adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500}
|
||||
elif mins_left > 2:
|
||||
# Goalie-pull zone: 2-goal penalty DECREASES
|
||||
adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600}
|
||||
else:
|
||||
# Final 2 min: 2-goal deficit with active goalie pull is exciting
|
||||
adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700}
|
||||
score_differential_adjustment = adj.get(
|
||||
score_difference, adj[4] + (score_difference - 4) * 100
|
||||
)
|
||||
# OT: always tied, no penalty needed
|
||||
|
||||
base_priority -= score_differential_adjustment
|
||||
|
||||
# ── 5. Late-3rd urgency bonus ─────────────────────────────────────────
|
||||
if period == 3 and time_remaining <= 720:
|
||||
if score_difference == 0:
|
||||
base_priority += 100
|
||||
elif score_difference == 1:
|
||||
base_priority += 60
|
||||
|
||||
if period == 3 and time_remaining <= 360:
|
||||
if score_difference == 0:
|
||||
base_priority += 50
|
||||
elif score_difference == 1:
|
||||
base_priority += 30
|
||||
|
||||
# ── 6. Closeness bonus ───────────────────────────────────────────────
|
||||
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
|
||||
|
||||
# ── 7. Time priority (non-linear — final minutes weighted more) ─────
|
||||
time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5)
|
||||
elapsed_fraction = (
|
||||
max(0.0, (period_length - time_remaining) / period_length)
|
||||
if period_length
|
||||
else 0
|
||||
)
|
||||
time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier
|
||||
|
||||
# ── 8. Power play bonus ───────────────────────────────────────────────
|
||||
pp_bonus = 0
|
||||
situation = game.get("situation", {})
|
||||
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
|
||||
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
|
||||
if "PP" in home_descs or "PP" in away_descs:
|
||||
man_advantage = _get_man_advantage(situation)
|
||||
advantage_mult = 1.0 if man_advantage <= 1 else 1.6
|
||||
if period >= 4:
|
||||
pp_bonus = int(200 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 300:
|
||||
pp_bonus = int(150 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 720:
|
||||
pp_bonus = int(100 * advantage_mult)
|
||||
elif period == 3:
|
||||
pp_bonus = int(50 * advantage_mult)
|
||||
else:
|
||||
pp_bonus = int(30 * advantage_mult)
|
||||
|
||||
# ── 9. Empty net bonus ───────────────────────────────────────────────
|
||||
en_bonus = 0
|
||||
if "EN" in home_descs or "EN" in away_descs:
|
||||
if period >= 4:
|
||||
en_bonus = 250
|
||||
elif period == 3 and time_remaining <= 180:
|
||||
en_bonus = 200
|
||||
elif period == 3 and time_remaining <= 360:
|
||||
en_bonus = 150
|
||||
else:
|
||||
en_bonus = 75
|
||||
|
||||
logger.debug(
|
||||
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
|
||||
"closeness: %s, pp: %s, en: %s",
|
||||
base_priority,
|
||||
time_priority,
|
||||
matchup_bonus,
|
||||
closeness_bonus,
|
||||
pp_bonus,
|
||||
en_bonus,
|
||||
)
|
||||
|
||||
final_priority = int(
|
||||
base_priority
|
||||
+ time_priority
|
||||
+ matchup_bonus
|
||||
+ closeness_bonus
|
||||
+ pp_bonus
|
||||
+ en_bonus
|
||||
)
|
||||
|
||||
# Pushes intermission games to the bottom, retains relative sort order
|
||||
if game["clock"]["inIntermission"]:
|
||||
return {**_zero, "total": -2000 - time_remaining}
|
||||
|
||||
return {
|
||||
"base": base_priority,
|
||||
"time": int(time_priority),
|
||||
"matchup_bonus": int(matchup_bonus),
|
||||
"closeness": closeness_bonus,
|
||||
"power_play": pp_bonus,
|
||||
"empty_net": en_bonus,
|
||||
"total": final_priority,
|
||||
}
|
||||
|
||||
|
||||
def calculate_game_priority(game):
|
||||
return _priority_components(game)["total"]
|
||||
|
||||
|
||||
def get_team_standings(team_name):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT league_sequence, league_l10_sequence,
|
||||
division_abbrev, conference_abbrev,
|
||||
games_played, wildcard_sequence
|
||||
FROM standings
|
||||
WHERE team_common_name = ?
|
||||
""",
|
||||
(team_name,),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
if result:
|
||||
return {
|
||||
"league_sequence": result[0],
|
||||
"league_l10_sequence": result[1],
|
||||
"division_abbrev": result[2],
|
||||
"conference_abbrev": result[3],
|
||||
"games_played": result[4],
|
||||
"wildcard_sequence": result[5],
|
||||
}
|
||||
return {
|
||||
"league_sequence": 0,
|
||||
"league_l10_sequence": 0,
|
||||
"division_abbrev": None,
|
||||
"conference_abbrev": None,
|
||||
"games_played": 0,
|
||||
"wildcard_sequence": 32,
|
||||
}
|
||||
|
||||
|
||||
def _playoff_importance(game):
|
||||
"""Importance for playoff games based on series context and round."""
|
||||
series = game.get("seriesStatus", {})
|
||||
if not series:
|
||||
# No series data available — flat playoff bonus
|
||||
return {
|
||||
"season_weight": 1.0,
|
||||
"playoff_relevance": 0.50,
|
||||
"rivalry": 1.0,
|
||||
"total": 100,
|
||||
}
|
||||
|
||||
round_num = series.get("round", 1)
|
||||
top_wins = series.get("topSeedWins", 0)
|
||||
bottom_wins = series.get("bottomSeedWins", 0)
|
||||
max_wins = max(top_wins, bottom_wins)
|
||||
min_wins = min(top_wins, bottom_wins)
|
||||
|
||||
round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0)
|
||||
|
||||
if max_wins == 3 and min_wins == 3:
|
||||
series_factor = 1.0
|
||||
elif max_wins == 3:
|
||||
series_factor = 0.85
|
||||
elif max_wins == 2 and min_wins == 2:
|
||||
series_factor = 0.70
|
||||
elif max_wins == 2:
|
||||
series_factor = 0.55
|
||||
else:
|
||||
series_factor = 0.40
|
||||
|
||||
importance = min(int(series_factor * round_mult * 200), 200)
|
||||
|
||||
return {
|
||||
"season_weight": round_mult,
|
||||
"playoff_relevance": series_factor,
|
||||
"rivalry": 1.0,
|
||||
"total": importance,
|
||||
}
|
||||
|
||||
|
||||
def _importance_components(game):
|
||||
"""Return a dict of all importance components plus the final total."""
|
||||
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
|
||||
|
||||
if game["gameState"] in ("FINAL", "OFF"):
|
||||
return _zero
|
||||
if game.get("gameType", 2) == 3:
|
||||
return _playoff_importance(game)
|
||||
if game.get("gameType", 2) != 2:
|
||||
return _zero
|
||||
|
||||
home_st = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||
|
||||
# Season weight — near-zero before game 30, sharp ramp 55-70, max at 82
|
||||
avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2
|
||||
if avg_gp <= 30:
|
||||
season_weight = 0.05
|
||||
else:
|
||||
t = (avg_gp - 30) / (82 - 30)
|
||||
season_weight = min(t**1.8, 1.0)
|
||||
|
||||
# Playoff relevance — peaks for bubble teams (wildcard rank ~17-19)
|
||||
best_wc = min(
|
||||
home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32
|
||||
)
|
||||
if best_wc <= 12:
|
||||
playoff_relevance = 0.60
|
||||
elif best_wc <= 16:
|
||||
playoff_relevance = 0.85
|
||||
elif best_wc <= 19:
|
||||
playoff_relevance = 1.00
|
||||
elif best_wc <= 23:
|
||||
playoff_relevance = 0.65
|
||||
else:
|
||||
playoff_relevance = 0.15
|
||||
|
||||
# Division/conference rivalry multiplier
|
||||
home_div = home_st["division_abbrev"]
|
||||
away_div = away_st["division_abbrev"]
|
||||
home_conf = home_st["conference_abbrev"]
|
||||
away_conf = away_st["conference_abbrev"]
|
||||
if home_div and away_div and home_div == away_div:
|
||||
rivalry_multiplier = 1.4
|
||||
elif home_conf and away_conf and home_conf == away_conf:
|
||||
rivalry_multiplier = 1.2
|
||||
else:
|
||||
rivalry_multiplier = 1.0
|
||||
|
||||
raw = season_weight * playoff_relevance * rivalry_multiplier
|
||||
importance = max(0, min(int((raw / 1.4) * 150), 150))
|
||||
|
||||
logger.debug(
|
||||
"importance components — season_weight: %.3f, playoff_relevance: %.2f, "
|
||||
"rivalry: %.1f, importance: %s",
|
||||
season_weight,
|
||||
playoff_relevance,
|
||||
rivalry_multiplier,
|
||||
importance,
|
||||
)
|
||||
|
||||
return {
|
||||
"season_weight": round(season_weight, 3),
|
||||
"playoff_relevance": playoff_relevance,
|
||||
"rivalry": rivalry_multiplier,
|
||||
"total": importance,
|
||||
}
|
||||
|
||||
|
||||
def calculate_game_importance(game):
|
||||
return _importance_components(game)["total"]
|
||||
|
||||
|
||||
def utc_to_eastern(utc_time):
|
||||
utc_datetime = datetime.strptime(utc_time, "%Y-%m-%dT%H:%M:%SZ")
|
||||
eastern_datetime = utc_datetime.replace(tzinfo=timezone.utc).astimezone(EASTERN)
|
||||
return eastern_datetime.strftime("%I:%M %p")
|
||||
+49
-17
@@ -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')
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route('/scoreboard')
|
||||
|
||||
@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"})
|
||||
|
||||
@@ -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)
|
||||
@@ -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=19, minute=00, second=0, microsecond=0) # 7:00 PM EST
|
||||
end_time_evening = now.replace(hour=2, 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
|
||||
@@ -1,158 +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"]
|
||||
est_time = utc_to_est_time(utc_time)
|
||||
# Check if the hour starts with a zero
|
||||
if est_time.startswith("0"):
|
||||
est_time = est_time[1:] # Drop the leading zero
|
||||
return est_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) * 20
|
||||
|
||||
# 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
|
||||
score_differential_adjustment = 0
|
||||
|
||||
if score_difference > 3:
|
||||
score_differential_adjustment += 500
|
||||
elif score_difference > 2:
|
||||
score_differential_adjustment += 350
|
||||
elif score_difference > 1:
|
||||
score_differential_adjustment += 100
|
||||
|
||||
if period == 3 and time_remaining <= 300:
|
||||
score_differential_adjustment = score_differential_adjustment * 2
|
||||
|
||||
base_priority -= score_differential_adjustment
|
||||
|
||||
# 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 (-2000 - time_remaining)
|
||||
|
||||
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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+225
-219
@@ -1,243 +1,249 @@
|
||||
// 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(650, 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 / 650) * 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 <=300) {
|
||||
gauge.style.backgroundColor = '#4A90E2'
|
||||
} else if (score <= 500) {
|
||||
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>
|
||||
</div>
|
||||
${dot}
|
||||
</div>
|
||||
${teamRow(game, 'Away', 'live')}
|
||||
${teamRow(game, 'Home', 'live')}
|
||||
${ppIndicator(game)}
|
||||
${hype}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPreGame(game) {
|
||||
return `
|
||||
<div class="game-box">
|
||||
<div class="card-header">
|
||||
<div class="badges">
|
||||
<span class="badge">${game['Start Time']}</span>
|
||||
</div>
|
||||
</div>
|
||||
${teamRow(game, 'Away', 'pre')}
|
||||
${teamRow(game, 'Home', 'pre')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFinalGame(game) {
|
||||
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
|
||||
const label = labels[game['Last Period Type']] ?? 'Final';
|
||||
return `
|
||||
<div class="game-box">
|
||||
<div class="card-header">
|
||||
<div class="badges">
|
||||
<span class="badge badge-muted">${label}</span>
|
||||
</div>
|
||||
</div>
|
||||
${teamRow(game, 'Away', 'final')}
|
||||
${teamRow(game, 'Home', 'final')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Team Row ─────────────────────────────────────────
|
||||
|
||||
function teamRow(game, side, state) {
|
||||
const name = game[`${side} Team`];
|
||||
const logo = game[`${side} Logo`];
|
||||
const score = game[`${side} Score`];
|
||||
const sog = game[`${side} Shots`];
|
||||
const record = game[`${side} Record`];
|
||||
|
||||
const sogHtml = (state === 'live' || state === 'final') && sog !== undefined
|
||||
? `<span class="team-sog">${sog} SOG</span>` : '';
|
||||
|
||||
const right = state === 'pre'
|
||||
? `<span class="team-record">${record}</span>`
|
||||
: `<span class="team-score">${score}</span>`;
|
||||
|
||||
return `
|
||||
<div class="team-row">
|
||||
<img src="${logo}" alt="${name} logo" class="team-logo">
|
||||
<div class="team-meta">
|
||||
<span class="team-name">${name}</span>
|
||||
${sogHtml}
|
||||
</div>
|
||||
${right}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ppIndicator(game) {
|
||||
const awayPP = game['Away Power Play'];
|
||||
const homePP = game['Home Power Play'];
|
||||
const pp = awayPP || homePP;
|
||||
if (!pp) return '';
|
||||
|
||||
const team = awayPP ? game['Away Team'] : game['Home Team'];
|
||||
const timeStr = pp.replace('PP ', '');
|
||||
const seconds = timeToSeconds(timeStr);
|
||||
const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`;
|
||||
|
||||
return `
|
||||
<div class="pp-indicator">
|
||||
<span class="pp-label">PP</span>
|
||||
<span class="pp-team">${team}</span>
|
||||
<span class="pp-clock" ${attrs}>${timeStr}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Gauge ────────────────────────────────────────────
|
||||
|
||||
function updateGauges() {
|
||||
document.querySelectorAll('.gauge').forEach(el => {
|
||||
const score = Math.min(1000, Math.max(0, parseInt(el.dataset.score, 10)));
|
||||
el.style.width = `${(score / 1000) * 100}%`;
|
||||
el.style.backgroundColor = score <= 350 ? '#4a90e2'
|
||||
: score <= 650 ? '#f97316'
|
||||
: '#ef4444';
|
||||
});
|
||||
}
|
||||
|
||||
const CLOCK_SYNC_THRESHOLD = 60; // seconds — only resync from API in final 60s
|
||||
|
||||
// ── Clock ─────────────────────────────────────────────
|
||||
|
||||
function timeToSeconds(str) {
|
||||
if (!str || str === 'END') return 0;
|
||||
const [m, s] = str.split(':').map(Number);
|
||||
return m * 60 + s;
|
||||
}
|
||||
|
||||
function secondsToTime(s) {
|
||||
if (s <= 0) return 'END';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function snapshotClocks(grid) {
|
||||
const snapshot = new Map();
|
||||
grid.querySelectorAll('[data-game-key]').forEach(card => {
|
||||
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
|
||||
if (!badge) return;
|
||||
const seconds = parseInt(badge.dataset.seconds, 10);
|
||||
const receivedAt = parseInt(badge.dataset.receivedAt, 10);
|
||||
const elapsed = Math.floor((Date.now() - receivedAt) / 1000);
|
||||
const current = Math.max(0, seconds - elapsed);
|
||||
snapshot.set(card.dataset.gameKey, { current, ts: Date.now() });
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreClocks(grid, snapshot) {
|
||||
grid.querySelectorAll('[data-game-key]').forEach(card => {
|
||||
const prior = snapshot.get(card.dataset.gameKey);
|
||||
if (!prior) return;
|
||||
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
|
||||
if (!badge) return;
|
||||
// Only restore if we're outside the final sync window
|
||||
if (prior.current > CLOCK_SYNC_THRESHOLD) {
|
||||
badge.dataset.seconds = prior.current;
|
||||
badge.dataset.receivedAt = prior.ts;
|
||||
badge.textContent = secondsToTime(prior.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+310
-305
@@ -1,381 +1,386 @@
|
||||
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%;
|
||||
.team-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.live-state {
|
||||
position: absolute;
|
||||
top: 4%;
|
||||
left: 4%;
|
||||
background-color: #0b6e31;
|
||||
padding: 1.5%;
|
||||
border-radius: 5px;
|
||||
.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%;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Power Play Indicator ───────────────────────── */
|
||||
|
||||
.pp-indicator {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.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%;
|
||||
.pp-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--red);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
.pp-team {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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;
|
||||
.pp-clock {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--red);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#live-games-section {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
/* ── Hype Meter ─────────────────────────────────── */
|
||||
|
||||
.hype-meter {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
#pre-games-section {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
.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;
|
||||
}
|
||||
|
||||
#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;
|
||||
.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;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1.25rem 2.5rem 3rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.game-box {
|
||||
padding: 1.25rem 1.25rem 1.125rem;
|
||||
}
|
||||
|
||||
.team-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.team-score {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.hype-label {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
--card-w: 100%;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.final-state {
|
||||
top: 5%;
|
||||
left: 3.5%;
|
||||
padding: 1.5%;
|
||||
border-radius: 5px;
|
||||
font-size: 72%;
|
||||
width: auto;
|
||||
height: 7.5%;
|
||||
}
|
||||
|
||||
.pre-state {
|
||||
top: 5%;
|
||||
left: 3.5%;
|
||||
padding: 1.5%;
|
||||
border-radius: 5px;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,771 @@
|
||||
import app.games
|
||||
from tests.conftest import make_game
|
||||
from app.games import (
|
||||
_get_man_advantage,
|
||||
calculate_game_importance,
|
||||
calculate_game_priority,
|
||||
convert_game_state,
|
||||
format_record,
|
||||
get_comeback_bonus,
|
||||
get_game_outcome,
|
||||
get_period,
|
||||
get_power_play_info,
|
||||
get_start_time,
|
||||
get_time_remaining,
|
||||
parse_games,
|
||||
utc_to_eastern,
|
||||
)
|
||||
|
||||
|
||||
class TestConvertGameState:
|
||||
def test_off_maps_to_final(self):
|
||||
assert convert_game_state("OFF") == "FINAL"
|
||||
|
||||
def test_crit_maps_to_live(self):
|
||||
assert convert_game_state("CRIT") == "LIVE"
|
||||
|
||||
def test_fut_maps_to_pre(self):
|
||||
assert convert_game_state("FUT") == "PRE"
|
||||
|
||||
def test_unknown_state_passes_through(self):
|
||||
assert convert_game_state("LIVE") == "LIVE"
|
||||
|
||||
|
||||
class TestProcessRecord:
|
||||
def test_na_returns_na(self):
|
||||
assert format_record("N/A") == "N/A"
|
||||
|
||||
def test_pads_single_digit_parts(self):
|
||||
assert format_record("5-3-1") == "05-03-01"
|
||||
|
||||
def test_already_padded_unchanged(self):
|
||||
assert format_record("40-25-10") == "40-25-10"
|
||||
|
||||
|
||||
class TestProcessPeriod:
|
||||
def test_pre_game_returns_zero(self):
|
||||
game = make_game(game_state="PRE")
|
||||
assert get_period(game) == 0
|
||||
|
||||
def test_fut_game_returns_zero(self):
|
||||
game = make_game(game_state="FUT")
|
||||
assert get_period(game) == 0
|
||||
|
||||
def test_final_game_returns_na(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert get_period(game) == "N/A"
|
||||
|
||||
def test_live_game_returns_period_number(self):
|
||||
game = make_game(game_state="LIVE", period=2)
|
||||
assert get_period(game) == 2
|
||||
|
||||
|
||||
class TestProcessTimeRemaining:
|
||||
def test_pre_game_returns_2000(self):
|
||||
game = make_game(game_state="FUT")
|
||||
assert get_time_remaining(game) == "20:00"
|
||||
|
||||
def test_final_game_returns_0000(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert get_time_remaining(game) == "00:00"
|
||||
|
||||
def test_live_game_returns_clock(self):
|
||||
game = make_game(game_state="LIVE", seconds_remaining=305)
|
||||
assert get_time_remaining(game) == "05:05"
|
||||
|
||||
def test_live_game_at_zero_returns_end(self):
|
||||
game = make_game(game_state="LIVE", seconds_remaining=0)
|
||||
assert get_time_remaining(game) == "END"
|
||||
|
||||
|
||||
class TestProcessStartTime:
|
||||
def test_pre_game_returns_est_time(self):
|
||||
game = make_game(game_state="FUT", start_time_utc="2024-04-10T23:00:00Z")
|
||||
result = get_start_time(game)
|
||||
assert result == "7:00 PM"
|
||||
|
||||
def test_pre_game_strips_leading_zero(self):
|
||||
game = make_game(game_state="FUT", start_time_utc="2024-04-10T22:00:00Z")
|
||||
result = get_start_time(game)
|
||||
assert not result.startswith("0")
|
||||
|
||||
def test_live_game_returns_na(self):
|
||||
game = make_game(game_state="LIVE")
|
||||
assert get_start_time(game) == "N/A"
|
||||
|
||||
|
||||
class TestGetGameOutcome:
|
||||
def test_final_game_returns_last_period_type(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert get_game_outcome(game, "FINAL") == "REG"
|
||||
|
||||
def test_live_game_returns_na(self):
|
||||
game = make_game(game_state="LIVE")
|
||||
assert get_game_outcome(game, "LIVE") == "N/A"
|
||||
|
||||
|
||||
class TestUtcToEstTime:
|
||||
def test_converts_utc_to_edt(self):
|
||||
# April is EDT (UTC-4): 23:00 UTC → 07:00 PM EDT
|
||||
result = utc_to_eastern("2024-04-10T23:00:00Z")
|
||||
assert result == "07:00 PM"
|
||||
|
||||
def test_converts_utc_to_est(self):
|
||||
# January is EST (UTC-5): 23:00 UTC → 06:00 PM EST
|
||||
result = utc_to_eastern("2024-01-15T23:00:00Z")
|
||||
assert result == "06:00 PM"
|
||||
|
||||
|
||||
class TestParseGames:
|
||||
def test_returns_empty_list_for_none(self):
|
||||
assert parse_games(None) == []
|
||||
|
||||
def test_returns_empty_list_for_empty_dict(self):
|
||||
assert parse_games({}) == []
|
||||
|
||||
|
||||
class TestGetPowerPlayInfo:
|
||||
def test_returns_empty_when_no_situation(self):
|
||||
game = make_game()
|
||||
assert get_power_play_info(game, "Maple Leafs") == ""
|
||||
|
||||
def test_returns_pp_info_for_away_team(self):
|
||||
game = make_game(away_name="Bruins")
|
||||
game["situation"] = {
|
||||
"awayTeam": {"situationDescriptions": ["PP"]},
|
||||
"homeTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
}
|
||||
assert get_power_play_info(game, "Bruins") == "PP 1:30"
|
||||
|
||||
def test_returns_pp_info_for_home_team(self):
|
||||
game = make_game(home_name="Maple Leafs", away_name="Bruins")
|
||||
game["situation"] = {
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "0:45",
|
||||
}
|
||||
assert get_power_play_info(game, "Maple Leafs") == "PP 0:45"
|
||||
|
||||
|
||||
class TestGetManAdvantage:
|
||||
def test_standard_5v4(self):
|
||||
# 1451: away 1G+4S=5, home 5S+1G=6 → advantage=1
|
||||
assert _get_man_advantage({"situationCode": "1451"}) == 1
|
||||
|
||||
def test_5v3(self):
|
||||
# 1351: away 1G+3S=4, home 5S+1G=6 → advantage=2
|
||||
assert _get_man_advantage({"situationCode": "1351"}) == 2
|
||||
|
||||
def test_4v3(self):
|
||||
# 1341: away 1G+3S=4, home 4S+1G=5 → advantage=1
|
||||
assert _get_man_advantage({"situationCode": "1341"}) == 1
|
||||
|
||||
def test_even_strength(self):
|
||||
# 1551: away 1G+5S=6, home 5S+1G=6 → advantage=0
|
||||
assert _get_man_advantage({"situationCode": "1551"}) == 0
|
||||
|
||||
def test_missing_code_defaults_to_1(self):
|
||||
assert _get_man_advantage({}) == 1
|
||||
|
||||
def test_invalid_code_defaults_to_1(self):
|
||||
assert _get_man_advantage({"situationCode": "abc"}) == 1
|
||||
|
||||
|
||||
class TestEmptyNetBonus:
|
||||
def test_en_late_p3_adds_200(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
|
||||
with_en = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=90,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||
"awayTeam": {"situationDescriptions": []},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200
|
||||
|
||||
def test_en_mid_p3_adds_150(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=3, seconds_remaining=300)
|
||||
with_en = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=300,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||
"awayTeam": {"situationDescriptions": []},
|
||||
"timeRemaining": "5:00",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150
|
||||
|
||||
def test_en_ot_adds_250(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||
with_en = make_game(
|
||||
game_state="LIVE",
|
||||
period=4,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||
"awayTeam": {"situationDescriptions": []},
|
||||
"timeRemaining": "10:00",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250
|
||||
|
||||
def test_en_stacks_with_pp(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
|
||||
with_both = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=90,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP", "EN"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
delta = calculate_game_priority(with_both) - calculate_game_priority(base)
|
||||
# PP late P3 = 150, EN late P3 = 200, total = 350
|
||||
assert delta == 350
|
||||
|
||||
|
||||
class TestMultiManAdvantage:
|
||||
def test_5v3_ot_pp_bonus_is_320(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||
with_5v3 = make_game(
|
||||
game_state="LIVE",
|
||||
period=4,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
"situationCode": "1351",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320
|
||||
|
||||
def test_standard_5v4_unchanged(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=4,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
"situationCode": "1451",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||
|
||||
|
||||
class TestCalculateGamePriority:
|
||||
def _live_game(
|
||||
self,
|
||||
period=3,
|
||||
seconds_remaining=300,
|
||||
home_score=2,
|
||||
away_score=1,
|
||||
in_intermission=False,
|
||||
):
|
||||
return make_game(
|
||||
game_state="LIVE",
|
||||
period=period,
|
||||
seconds_remaining=seconds_remaining,
|
||||
home_score=home_score,
|
||||
away_score=away_score,
|
||||
in_intermission=in_intermission,
|
||||
)
|
||||
|
||||
def test_returns_zero_for_final(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert calculate_game_priority(game) == 0
|
||||
|
||||
def test_returns_zero_for_pre(self):
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_priority(game) == 0
|
||||
|
||||
def test_intermission_returns_negative(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(in_intermission=True, seconds_remaining=0)
|
||||
assert calculate_game_priority(game) < 0
|
||||
|
||||
def test_score_diff_greater_than_3(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(home_score=5, away_score=0)
|
||||
result = calculate_game_priority(game)
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_score_diff_greater_than_2(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(home_score=4, away_score=1)
|
||||
result = calculate_game_priority(game)
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_score_diff_greater_than_1(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(home_score=3, away_score=1)
|
||||
result = calculate_game_priority(game)
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_late_3rd_tied_bonus(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(
|
||||
period=3, seconds_remaining=600, home_score=2, away_score=2
|
||||
)
|
||||
result = calculate_game_priority(game)
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_final_6_minutes_tied_bonus(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
game = self._live_game(
|
||||
period=3, seconds_remaining=300, home_score=2, away_score=2
|
||||
)
|
||||
result = calculate_game_priority(game)
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_playoff_ot_escalates_per_period(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=600, game_type=3)
|
||||
p5 = make_game(game_state="LIVE", period=5, seconds_remaining=600, game_type=3)
|
||||
assert calculate_game_priority(p5) > calculate_game_priority(p4)
|
||||
|
||||
def test_shootout_ranks_below_late_ot(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
ot = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
||||
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
|
||||
# Sudden-death OT is more exciting than a skills competition
|
||||
assert calculate_game_priority(ot) > calculate_game_priority(so)
|
||||
|
||||
def test_shootout_ranks_above_p2_blowout(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
|
||||
blowout = make_game(
|
||||
game_state="LIVE",
|
||||
period=2,
|
||||
seconds_remaining=600,
|
||||
home_score=5,
|
||||
away_score=1,
|
||||
game_type=2,
|
||||
)
|
||||
assert calculate_game_priority(so) > calculate_game_priority(blowout)
|
||||
|
||||
def test_playoff_p4_higher_than_regular_season_p4(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
reg = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
||||
playoff = make_game(
|
||||
game_state="LIVE", period=4, seconds_remaining=600, game_type=3
|
||||
)
|
||||
assert calculate_game_priority(playoff) > calculate_game_priority(reg)
|
||||
|
||||
def test_closeness_bonus_tied_beats_one_goal(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
tied = self._live_game(home_score=2, away_score=2)
|
||||
one_goal = self._live_game(home_score=2, away_score=1)
|
||||
assert calculate_game_priority(tied) > calculate_game_priority(one_goal)
|
||||
|
||||
def test_5_4_same_priority_as_1_0(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
high_scoring = self._live_game(home_score=5, away_score=4)
|
||||
low_scoring = self._live_game(home_score=1, away_score=0)
|
||||
assert calculate_game_priority(high_scoring) == calculate_game_priority(
|
||||
low_scoring
|
||||
)
|
||||
|
||||
def test_pp_in_ot_adds_200(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=4,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||
|
||||
def test_pp_late_p3_adds_150(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = self._live_game(period=3, seconds_remaining=240)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=240,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150
|
||||
|
||||
def test_pp_mid_p3_adds_100(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = self._live_game(period=3, seconds_remaining=600)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100
|
||||
|
||||
def test_pp_early_p3_adds_50(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = self._live_game(period=3, seconds_remaining=900)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=3,
|
||||
seconds_remaining=900,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50
|
||||
|
||||
def test_pp_p1_adds_30(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
base = self._live_game(period=1, seconds_remaining=600)
|
||||
with_pp = make_game(
|
||||
game_state="LIVE",
|
||||
period=1,
|
||||
seconds_remaining=600,
|
||||
situation={
|
||||
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30
|
||||
|
||||
def test_time_priority_increases_as_clock_runs(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
early = self._live_game(period=3, seconds_remaining=1100)
|
||||
late = self._live_game(period=3, seconds_remaining=200)
|
||||
assert calculate_game_priority(late) > calculate_game_priority(early)
|
||||
|
||||
|
||||
class TestGetComebackBonus:
|
||||
def setup_method(self):
|
||||
app.games._score_cache.clear()
|
||||
app.games._comeback_tracker.clear()
|
||||
|
||||
def test_returns_zero_on_first_call(self):
|
||||
game = make_game(home_score=2, away_score=1)
|
||||
assert get_comeback_bonus(game) == 0
|
||||
|
||||
def test_cache_populated_after_first_call(self):
|
||||
game = make_game(home_score=2, away_score=1)
|
||||
get_comeback_bonus(game)
|
||||
assert ("Maple Leafs", "Bruins") in app.games._score_cache
|
||||
|
||||
def test_no_bonus_for_one_goal_swing(self):
|
||||
# 1-goal swings are normal hockey, no bonus
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 1)
|
||||
game = make_game(home_score=1, away_score=1, period=3)
|
||||
assert get_comeback_bonus(game) == 0
|
||||
|
||||
def test_two_goal_recovery_in_p3(self):
|
||||
# Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=3)
|
||||
assert get_comeback_bonus(game) == 90 # 60*1.0 + 30
|
||||
|
||||
def test_three_goal_recovery_in_p3(self):
|
||||
# Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 3)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||
game = make_game(home_score=3, away_score=3, period=3)
|
||||
assert get_comeback_bonus(game) == 150 # 120*1.0 + 30
|
||||
|
||||
def test_partial_recovery_in_p3(self):
|
||||
# Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||
game = make_game(home_score=2, away_score=3, period=3)
|
||||
assert get_comeback_bonus(game) == 60 # 60*1.0
|
||||
|
||||
def test_bonus_persists_across_polls(self):
|
||||
# Set up a 2-goal recovery, then call again — bonus stays
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=3)
|
||||
first = get_comeback_bonus(game)
|
||||
second = get_comeback_bonus(game)
|
||||
assert first == second == 90
|
||||
|
||||
def test_period_multiplier_p1_lower(self):
|
||||
# P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=1)
|
||||
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
|
||||
|
||||
def test_ot_multiplier_higher(self):
|
||||
# OT: base=60, period_mult=1.2, tie=30
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=4)
|
||||
assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30)
|
||||
|
||||
def test_no_bonus_in_intermission(self):
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, in_intermission=True)
|
||||
assert get_comeback_bonus(game) == 0
|
||||
|
||||
def test_no_bonus_for_non_live_state(self):
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 3)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||
game = make_game(game_state="OFF", home_score=3, away_score=3)
|
||||
assert get_comeback_bonus(game) == 0
|
||||
|
||||
def test_tracker_builds_max_deficit_over_time(self):
|
||||
# Simulate progressive scoring: 0-1, 0-2, 1-2, 2-2
|
||||
key = ("Maple Leafs", "Bruins")
|
||||
get_comeback_bonus(make_game(home_score=0, away_score=1, period=1))
|
||||
get_comeback_bonus(make_game(home_score=0, away_score=2, period=1))
|
||||
get_comeback_bonus(make_game(home_score=1, away_score=2, period=2))
|
||||
result = get_comeback_bonus(make_game(home_score=2, away_score=2, period=3))
|
||||
assert app.games._comeback_tracker[key] == 2
|
||||
assert result == 90 # 60*1.0 + 30
|
||||
|
||||
|
||||
class TestCalculateGameImportance:
|
||||
def _standings(
|
||||
self,
|
||||
league_seq=10,
|
||||
l10_seq=10,
|
||||
div="ATL",
|
||||
conf="E",
|
||||
gp=65,
|
||||
wc=18,
|
||||
):
|
||||
return {
|
||||
"league_sequence": league_seq,
|
||||
"league_l10_sequence": l10_seq,
|
||||
"division_abbrev": div,
|
||||
"conference_abbrev": conf,
|
||||
"games_played": gp,
|
||||
"wildcard_sequence": wc,
|
||||
}
|
||||
|
||||
def test_playoff_game_gets_fallback_importance(self):
|
||||
game = make_game(game_type=3)
|
||||
assert calculate_game_importance(game) == 100
|
||||
|
||||
def test_playoff_game7_cup_final_is_max(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 4, "topSeedWins": 3, "bottomSeedWins": 3},
|
||||
)
|
||||
assert calculate_game_importance(game) == 200
|
||||
|
||||
def test_playoff_elimination_round1(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 1, "topSeedWins": 3, "bottomSeedWins": 2},
|
||||
)
|
||||
assert calculate_game_importance(game) == 170
|
||||
|
||||
def test_playoff_game1_round1_lowest(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 1, "topSeedWins": 0, "bottomSeedWins": 0},
|
||||
)
|
||||
assert calculate_game_importance(game) == 80
|
||||
|
||||
def test_playoff_later_rounds_more_important(self):
|
||||
series = {"topSeedWins": 2, "bottomSeedWins": 2}
|
||||
r1 = make_game(game_type=3, series_status={**series, "round": 1})
|
||||
r3 = make_game(game_type=3, series_status={**series, "round": 3})
|
||||
assert calculate_game_importance(r3) > calculate_game_importance(r1)
|
||||
|
||||
def test_returns_zero_for_final_game(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert calculate_game_importance(game) == 0
|
||||
|
||||
def test_near_zero_early_in_season(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=10, wc=18),
|
||||
)
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_importance(game) <= 10
|
||||
|
||||
def test_max_bonus_late_season_bubble_division_game(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_importance(game) == 150
|
||||
|
||||
def test_same_division_beats_same_conference(self, mocker):
|
||||
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_st_same_div = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_st_diff_div = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_st_same_div],
|
||||
)
|
||||
result_div = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_st_diff_div],
|
||||
)
|
||||
result_conf = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_div > result_conf
|
||||
|
||||
def test_same_conference_beats_different_conference(self, mocker):
|
||||
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_same_conf = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||
away_diff_conf = self._standings(gp=70, wc=18, div="PAC", conf="W")
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_same_conf],
|
||||
)
|
||||
result_same = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_diff_conf],
|
||||
)
|
||||
result_diff = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_same > result_diff
|
||||
|
||||
def test_bubble_teams_beat_safely_in_teams(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=18),
|
||||
)
|
||||
result_bubble = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=5),
|
||||
)
|
||||
result_safe = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_bubble > result_safe
|
||||
|
||||
def test_eliminated_teams_have_lowest_relevance(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=30),
|
||||
)
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) < 30
|
||||
|
||||
def test_result_is_non_negative_int(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=0, wc=32),
|
||||
)
|
||||
result = calculate_game_importance(make_game(game_state="FUT"))
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_result_never_exceeds_150(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) <= 150
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user