Compare commits
70 Commits
v4.0.1
..
108b77ed39
| Author | SHA1 | Date | |
|---|---|---|---|
| 108b77ed39 | |||
| 61202b2a70 | |||
| 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 | |||
| 2e85ced6ce | |||
| 5d65533ff5 | |||
| 085514ab16 | |||
| 960ff6e5ac | |||
| 04e29469dd | |||
| 360188114e | |||
| 982fdfb3c1 | |||
| 94f9cced2e | |||
| 3edb84c333 | |||
| 6ec9a7aef1 | |||
| dfb86f6fd5 | |||
| e5824cefc5 | |||
| 18ff48cc2c | |||
| 8c5de8602f | |||
| 9f4a6c966a | |||
| 4da3c2dfdd | |||
| 07ff5ac055 | |||
| fe7449537b | |||
| dd8d1ca12b |
@@ -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
|
/nhle_standings_response.txt
|
||||||
/app/data/nhl_standings.db
|
/app/data/nhl_standings.db
|
||||||
/app/data/scoreboard_data.json
|
/app/data/scoreboard_data.json
|
||||||
/__pycache__
|
|
||||||
/app/__pycache__
|
|
||||||
/app/scoreboard/__pycache__
|
|
||||||
nhl_standings.db
|
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.13-slim
|
||||||
FROM python:3.9-slim
|
|
||||||
|
|
||||||
# Set environment variables
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
PYTHONUNBUFFERED=1 \
|
||||||
ENV PYTHONUNBUFFERED 1
|
DATA_DIR=/app/app/data
|
||||||
|
|
||||||
# Set the working directory in the container
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the current directory contents into the container at /app
|
COPY requirements.txt .
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
# Install any needed dependencies specified in requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Create the directory for scoreboard data
|
COPY . .
|
||||||
RUN mkdir -p app/data
|
|
||||||
|
RUN mkdir -p $DATA_DIR
|
||||||
|
|
||||||
# Expose the Flask port
|
|
||||||
EXPOSE 2897
|
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"]
|
CMD ["python", "run.py"]
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
# NHL Scoreboard Web App
|
# 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
|
## Features
|
||||||
|
|
||||||
- Displays live NHL game scores.
|
- Live NHL game scores, shots on goal, and game state
|
||||||
- Sorts games based on priority to highlight the most exciting matchups.
|
- Power play indicators with live countdown clock
|
||||||
- Responsive design for desktop and mobile devices.
|
- 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
|
## Technologies Used
|
||||||
|
|
||||||
- **Python**: Backend scripting language.
|
- **Python / Flask** — backend and API polling
|
||||||
- **Flask**: Web framework for Python.
|
- **SQLite** — standings cache (refreshed every 10 minutes)
|
||||||
- **HTML/CSS**: Frontend markup and styling.
|
- **HTML / CSS / JavaScript** — frontend with auto-refresh every 10 seconds
|
||||||
- **JavaScript**: Client-side scripting for auto-refresh functionality.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -35,19 +54,12 @@ This web application displays live NHL game scores, team statistics, and game st
|
|||||||
python run.py
|
python run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Open your web browser and navigate to `http://localhost:2897` to view the scoreboard.
|
4. Open `http://localhost:2897` in your browser.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Special thanks to the NHL for providing the data through their API.
|
Data provided by the NHL via their public API.
|
||||||
|
|
||||||
## License
|
## 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 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__)
|
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")
|
||||||
+547
@@ -0,0 +1,547 @@
|
|||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.config import DB_PATH
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maps (home_team_name, away_team_name) -> (home_score, away_score)
|
||||||
|
_score_cache: dict[tuple[str, str], tuple[int, int]] = {}
|
||||||
|
|
||||||
|
# Maps (home_team_name, away_team_name) -> max score differential seen
|
||||||
|
_comeback_tracker: dict[tuple[str, str], int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def format_record(record):
|
||||||
|
if record == "N/A":
|
||||||
|
return "N/A"
|
||||||
|
else:
|
||||||
|
parts = record.split("-")
|
||||||
|
formatted_parts = [part.zfill(2) for part in parts]
|
||||||
|
return "-".join(formatted_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_games(scoreboard_data):
|
||||||
|
if not scoreboard_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
extracted_info = []
|
||||||
|
for game in scoreboard_data.get("games", []):
|
||||||
|
game_state = convert_game_state(game["gameState"])
|
||||||
|
priority_comps = _priority_components(game)
|
||||||
|
comeback = get_comeback_bonus(game)
|
||||||
|
importance_comps = _importance_components(game)
|
||||||
|
total_priority = priority_comps["total"] + comeback + importance_comps["total"]
|
||||||
|
extracted_info.append(
|
||||||
|
{
|
||||||
|
"Home Team": game["homeTeam"]["name"]["default"],
|
||||||
|
"Home Score": game["homeTeam"]["score"]
|
||||||
|
if game_state != "PRE"
|
||||||
|
else "N/A",
|
||||||
|
"Away Team": game["awayTeam"]["name"]["default"],
|
||||||
|
"Away Score": game["awayTeam"]["score"]
|
||||||
|
if game_state != "PRE"
|
||||||
|
else "N/A",
|
||||||
|
"Home Logo": game["homeTeam"]["logo"],
|
||||||
|
"Away Logo": game["awayTeam"]["logo"],
|
||||||
|
"Game State": game_state,
|
||||||
|
"Game Type": game.get("gameType", 2),
|
||||||
|
"Period": get_period(game),
|
||||||
|
"Time Remaining": get_time_remaining(game),
|
||||||
|
"Time Running": game["clock"]["running"]
|
||||||
|
if game_state == "LIVE"
|
||||||
|
else "N/A",
|
||||||
|
"Intermission": game["clock"]["inIntermission"]
|
||||||
|
if game_state == "LIVE"
|
||||||
|
else "N/A",
|
||||||
|
"Priority": total_priority,
|
||||||
|
"Hype Breakdown": {
|
||||||
|
"base": priority_comps["base"],
|
||||||
|
"time": priority_comps["time"],
|
||||||
|
"matchup_bonus": priority_comps["matchup_bonus"],
|
||||||
|
"closeness": priority_comps["closeness"],
|
||||||
|
"power_play": priority_comps["power_play"],
|
||||||
|
"empty_net": priority_comps["empty_net"],
|
||||||
|
"comeback": comeback,
|
||||||
|
"importance": importance_comps["total"],
|
||||||
|
"importance_season_weight": importance_comps["season_weight"],
|
||||||
|
"importance_playoff_relevance": importance_comps[
|
||||||
|
"playoff_relevance"
|
||||||
|
],
|
||||||
|
"importance_rivalry": importance_comps["rivalry"],
|
||||||
|
"total": total_priority,
|
||||||
|
},
|
||||||
|
"Start Time": get_start_time(game),
|
||||||
|
"Start Time UTC": game.get("startTimeUTC", ""),
|
||||||
|
"Home Record": format_record(game["homeTeam"]["record"])
|
||||||
|
if game["gameState"] in ["PRE", "FUT"]
|
||||||
|
else "N/A",
|
||||||
|
"Away Record": format_record(game["awayTeam"]["record"])
|
||||||
|
if game["gameState"] in ["PRE", "FUT"]
|
||||||
|
else "N/A",
|
||||||
|
"Home Shots": game["homeTeam"]["sog"]
|
||||||
|
if game["gameState"] not in ["PRE", "FUT"]
|
||||||
|
else 0,
|
||||||
|
"Away Shots": game["awayTeam"]["sog"]
|
||||||
|
if game["gameState"] not in ["PRE", "FUT"]
|
||||||
|
else 0,
|
||||||
|
"Home Power Play": get_power_play_info(
|
||||||
|
game, game["homeTeam"]["name"]["default"]
|
||||||
|
),
|
||||||
|
"Away Power Play": get_power_play_info(
|
||||||
|
game, game["awayTeam"]["name"]["default"]
|
||||||
|
),
|
||||||
|
"Last Period Type": get_game_outcome(game, game_state),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sort_key(g):
|
||||||
|
if g["Game State"] == "PRE":
|
||||||
|
# Earliest start first — ISO-8601 sorts correctly as a string
|
||||||
|
return (0, g["Start Time UTC"], 0)
|
||||||
|
# LIVE / FINAL — highest priority first
|
||||||
|
return (1, "", -g["Priority"])
|
||||||
|
|
||||||
|
return sorted(extracted_info, key=_sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_comeback_bonus(game):
|
||||||
|
"""Persistent comeback bonus that scales with deficit recovered.
|
||||||
|
|
||||||
|
Tracks the maximum score differential seen in the game. A recovery of 2+
|
||||||
|
goals earns a sustained bonus that persists as long as the game remains
|
||||||
|
close. One-goal swings are normal hockey and earn no bonus.
|
||||||
|
"""
|
||||||
|
if game["gameState"] not in ("LIVE", "CRIT"):
|
||||||
|
return 0
|
||||||
|
if game["clock"]["inIntermission"]:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
home_name = game["homeTeam"]["name"]["default"]
|
||||||
|
away_name = game["awayTeam"]["name"]["default"]
|
||||||
|
key = (home_name, away_name)
|
||||||
|
|
||||||
|
home_score = game["homeTeam"]["score"]
|
||||||
|
away_score = game["awayTeam"]["score"]
|
||||||
|
current_diff = abs(home_score - away_score)
|
||||||
|
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||||
|
|
||||||
|
tracker_max = _comeback_tracker.get(key, 0)
|
||||||
|
if key in _score_cache:
|
||||||
|
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
|
||||||
|
tracker_max = max(tracker_max, prev_diff)
|
||||||
|
_comeback_tracker[key] = tracker_max
|
||||||
|
_score_cache[key] = (home_score, away_score)
|
||||||
|
|
||||||
|
recovery = tracker_max - current_diff
|
||||||
|
if recovery < 2 or tracker_max < 2:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
base = {2: 60, 3: 120}.get(recovery, 160)
|
||||||
|
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
|
||||||
|
tie_bonus = 30 if current_diff == 0 else 0
|
||||||
|
|
||||||
|
return int(base * period_mult + tie_bonus)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_game_state(game_state):
|
||||||
|
state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"}
|
||||||
|
return state_mapping.get(game_state, game_state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_period(game):
|
||||||
|
if game["gameState"] in ["PRE", "FUT"]:
|
||||||
|
return 0
|
||||||
|
elif game["gameState"] in ["FINAL", "OFF"]:
|
||||||
|
return "N/A"
|
||||||
|
else:
|
||||||
|
return game["periodDescriptor"]["number"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_remaining(game):
|
||||||
|
if game["gameState"] in ["PRE", "FUT"]:
|
||||||
|
return "20:00"
|
||||||
|
elif game["gameState"] in ["FINAL", "OFF"]:
|
||||||
|
return "00:00"
|
||||||
|
else:
|
||||||
|
time_remaining = game["clock"]["timeRemaining"]
|
||||||
|
return "END" if time_remaining == "00:00" else time_remaining
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_time(game):
|
||||||
|
if game["gameState"] in ["PRE", "FUT"]:
|
||||||
|
utc_time = game["startTimeUTC"]
|
||||||
|
est_time = utc_to_eastern(utc_time)
|
||||||
|
return est_time.lstrip("0")
|
||||||
|
else:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def get_power_play_info(game, team_name):
|
||||||
|
situation = game.get("situation", {})
|
||||||
|
if not situation:
|
||||||
|
return ""
|
||||||
|
time_remaining = situation.get("timeRemaining", "")
|
||||||
|
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
|
||||||
|
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
|
||||||
|
if "PP" in home_descs and game["homeTeam"]["name"]["default"] == team_name:
|
||||||
|
return f"PP {time_remaining}"
|
||||||
|
if "PP" in away_descs and game["awayTeam"]["name"]["default"] == team_name:
|
||||||
|
return f"PP {time_remaining}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_outcome(game, game_state):
|
||||||
|
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_man_advantage(situation):
|
||||||
|
"""Parse situationCode for player count difference.
|
||||||
|
Format: [away_goalie][away_skaters][home_skaters][home_goalie]."""
|
||||||
|
code = situation.get("situationCode", "")
|
||||||
|
if len(code) != 4 or not code.isdigit():
|
||||||
|
return 1
|
||||||
|
away_total = int(code[0]) + int(code[1])
|
||||||
|
home_total = int(code[2]) + int(code[3])
|
||||||
|
return abs(home_total - away_total)
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_components(game):
|
||||||
|
"""Return a dict of all priority components plus the final total."""
|
||||||
|
_zero = {
|
||||||
|
"base": 0,
|
||||||
|
"time": 0,
|
||||||
|
"matchup_bonus": 0,
|
||||||
|
"closeness": 0,
|
||||||
|
"power_play": 0,
|
||||||
|
"empty_net": 0,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
|
||||||
|
return _zero
|
||||||
|
|
||||||
|
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||||
|
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
|
||||||
|
home_score = game["homeTeam"]["score"]
|
||||||
|
away_score = game["awayTeam"]["score"]
|
||||||
|
score_difference = abs(home_score - away_score)
|
||||||
|
is_playoff = game.get("gameType", 2) == 3
|
||||||
|
|
||||||
|
# ── 1. Base priority by period ────────────────────────────────────────
|
||||||
|
if is_playoff:
|
||||||
|
base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150)
|
||||||
|
else:
|
||||||
|
base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150)
|
||||||
|
|
||||||
|
# ── 2. Period length for time calculations ────────────────────────────
|
||||||
|
period_length = (1200 if is_playoff else 300) if period >= 4 else 1200
|
||||||
|
|
||||||
|
# ── 3. Standings-quality matchup bonus ───────────────────────────────
|
||||||
|
# Invert rank so that #1 (best) contributes the most quality points.
|
||||||
|
# league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32.
|
||||||
|
home_standings = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||||
|
away_standings = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||||
|
home_quality = (33 - home_standings["league_sequence"]) + (
|
||||||
|
33 - home_standings["league_l10_sequence"]
|
||||||
|
)
|
||||||
|
away_quality = (33 - away_standings["league_sequence"]) + (
|
||||||
|
33 - away_standings["league_l10_sequence"]
|
||||||
|
)
|
||||||
|
# Higher period = matchup matters less (any OT is exciting regardless of teams)
|
||||||
|
matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0)
|
||||||
|
matchup_bonus = (home_quality + away_quality) * matchup_multiplier
|
||||||
|
|
||||||
|
# ── Shootout: flat priority, no time component (rounds, not clock) ───
|
||||||
|
if period == 5 and not is_playoff:
|
||||||
|
so_base = 550
|
||||||
|
so_closeness = 80
|
||||||
|
so_matchup = (home_quality + away_quality) * 1.0
|
||||||
|
so_total = int(so_base + so_closeness + so_matchup)
|
||||||
|
return {
|
||||||
|
"base": so_base,
|
||||||
|
"time": 0,
|
||||||
|
"matchup_bonus": int(so_matchup),
|
||||||
|
"closeness": so_closeness,
|
||||||
|
"power_play": 0,
|
||||||
|
"empty_net": 0,
|
||||||
|
"total": so_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 4. Score-differential penalty (period-aware) ───────────────────────
|
||||||
|
score_differential_adjustment = 0
|
||||||
|
if period <= 2:
|
||||||
|
adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350}
|
||||||
|
score_differential_adjustment = adj.get(
|
||||||
|
score_difference, 350 + (score_difference - 4) * 100
|
||||||
|
)
|
||||||
|
elif period == 3:
|
||||||
|
mins_left = time_remaining / 60
|
||||||
|
if mins_left > 10:
|
||||||
|
adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400}
|
||||||
|
elif mins_left > 5:
|
||||||
|
adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500}
|
||||||
|
elif mins_left > 2:
|
||||||
|
# Goalie-pull zone: 2-goal penalty DECREASES
|
||||||
|
adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600}
|
||||||
|
else:
|
||||||
|
# Final 2 min: 2-goal deficit with active goalie pull is exciting
|
||||||
|
adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700}
|
||||||
|
score_differential_adjustment = adj.get(
|
||||||
|
score_difference, adj[4] + (score_difference - 4) * 100
|
||||||
|
)
|
||||||
|
# OT: always tied, no penalty needed
|
||||||
|
|
||||||
|
base_priority -= score_differential_adjustment
|
||||||
|
|
||||||
|
# ── 5. Late-3rd urgency bonus ─────────────────────────────────────────
|
||||||
|
if period == 3 and time_remaining <= 720:
|
||||||
|
if score_difference == 0:
|
||||||
|
base_priority += 100
|
||||||
|
elif score_difference == 1:
|
||||||
|
base_priority += 60
|
||||||
|
|
||||||
|
if period == 3 and time_remaining <= 360:
|
||||||
|
if score_difference == 0:
|
||||||
|
base_priority += 50
|
||||||
|
elif score_difference == 1:
|
||||||
|
base_priority += 30
|
||||||
|
|
||||||
|
# ── 6. Closeness bonus ───────────────────────────────────────────────
|
||||||
|
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
|
||||||
|
|
||||||
|
# ── 7. Time priority (non-linear — final minutes weighted more) ─────
|
||||||
|
time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5)
|
||||||
|
elapsed_fraction = (
|
||||||
|
max(0.0, (period_length - time_remaining) / period_length)
|
||||||
|
if period_length
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier
|
||||||
|
|
||||||
|
# ── 8. Power play bonus ───────────────────────────────────────────────
|
||||||
|
pp_bonus = 0
|
||||||
|
situation = game.get("situation", {})
|
||||||
|
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
|
||||||
|
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
|
||||||
|
if "PP" in home_descs or "PP" in away_descs:
|
||||||
|
man_advantage = _get_man_advantage(situation)
|
||||||
|
advantage_mult = 1.0 if man_advantage <= 1 else 1.6
|
||||||
|
if period >= 4:
|
||||||
|
pp_bonus = int(200 * advantage_mult)
|
||||||
|
elif period == 3 and time_remaining <= 300:
|
||||||
|
pp_bonus = int(150 * advantage_mult)
|
||||||
|
elif period == 3 and time_remaining <= 720:
|
||||||
|
pp_bonus = int(100 * advantage_mult)
|
||||||
|
elif period == 3:
|
||||||
|
pp_bonus = int(50 * advantage_mult)
|
||||||
|
else:
|
||||||
|
pp_bonus = int(30 * advantage_mult)
|
||||||
|
|
||||||
|
# ── 9. Empty net bonus ───────────────────────────────────────────────
|
||||||
|
en_bonus = 0
|
||||||
|
if "EN" in home_descs or "EN" in away_descs:
|
||||||
|
if period >= 4:
|
||||||
|
en_bonus = 250
|
||||||
|
elif period == 3 and time_remaining <= 180:
|
||||||
|
en_bonus = 200
|
||||||
|
elif period == 3 and time_remaining <= 360:
|
||||||
|
en_bonus = 150
|
||||||
|
else:
|
||||||
|
en_bonus = 75
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
|
||||||
|
"closeness: %s, pp: %s, en: %s",
|
||||||
|
base_priority,
|
||||||
|
time_priority,
|
||||||
|
matchup_bonus,
|
||||||
|
closeness_bonus,
|
||||||
|
pp_bonus,
|
||||||
|
en_bonus,
|
||||||
|
)
|
||||||
|
|
||||||
|
final_priority = int(
|
||||||
|
base_priority
|
||||||
|
+ time_priority
|
||||||
|
+ matchup_bonus
|
||||||
|
+ closeness_bonus
|
||||||
|
+ pp_bonus
|
||||||
|
+ en_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pushes intermission games to the bottom, retains relative sort order
|
||||||
|
if game["clock"]["inIntermission"]:
|
||||||
|
return {**_zero, "total": -2000 - time_remaining}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base": base_priority,
|
||||||
|
"time": int(time_priority),
|
||||||
|
"matchup_bonus": int(matchup_bonus),
|
||||||
|
"closeness": closeness_bonus,
|
||||||
|
"power_play": pp_bonus,
|
||||||
|
"empty_net": en_bonus,
|
||||||
|
"total": final_priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_game_priority(game):
|
||||||
|
return _priority_components(game)["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_standings(team_name):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT league_sequence, league_l10_sequence,
|
||||||
|
division_abbrev, conference_abbrev,
|
||||||
|
games_played, wildcard_sequence
|
||||||
|
FROM standings
|
||||||
|
WHERE team_common_name = ?
|
||||||
|
""",
|
||||||
|
(team_name,),
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"league_sequence": result[0],
|
||||||
|
"league_l10_sequence": result[1],
|
||||||
|
"division_abbrev": result[2],
|
||||||
|
"conference_abbrev": result[3],
|
||||||
|
"games_played": result[4],
|
||||||
|
"wildcard_sequence": result[5],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"league_sequence": 0,
|
||||||
|
"league_l10_sequence": 0,
|
||||||
|
"division_abbrev": None,
|
||||||
|
"conference_abbrev": None,
|
||||||
|
"games_played": 0,
|
||||||
|
"wildcard_sequence": 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _playoff_importance(game):
|
||||||
|
"""Importance for playoff games based on series context and round."""
|
||||||
|
series = game.get("seriesStatus", {})
|
||||||
|
if not series:
|
||||||
|
# No series data available — flat playoff bonus
|
||||||
|
return {
|
||||||
|
"season_weight": 1.0,
|
||||||
|
"playoff_relevance": 0.50,
|
||||||
|
"rivalry": 1.0,
|
||||||
|
"total": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
round_num = series.get("round", 1)
|
||||||
|
top_wins = series.get("topSeedWins", 0)
|
||||||
|
bottom_wins = series.get("bottomSeedWins", 0)
|
||||||
|
max_wins = max(top_wins, bottom_wins)
|
||||||
|
min_wins = min(top_wins, bottom_wins)
|
||||||
|
|
||||||
|
round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0)
|
||||||
|
|
||||||
|
if max_wins == 3 and min_wins == 3:
|
||||||
|
series_factor = 1.0
|
||||||
|
elif max_wins == 3:
|
||||||
|
series_factor = 0.85
|
||||||
|
elif max_wins == 2 and min_wins == 2:
|
||||||
|
series_factor = 0.70
|
||||||
|
elif max_wins == 2:
|
||||||
|
series_factor = 0.55
|
||||||
|
else:
|
||||||
|
series_factor = 0.40
|
||||||
|
|
||||||
|
importance = min(int(series_factor * round_mult * 200), 200)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"season_weight": round_mult,
|
||||||
|
"playoff_relevance": series_factor,
|
||||||
|
"rivalry": 1.0,
|
||||||
|
"total": importance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _importance_components(game):
|
||||||
|
"""Return a dict of all importance components plus the final total."""
|
||||||
|
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
|
||||||
|
|
||||||
|
if game["gameState"] in ("FINAL", "OFF"):
|
||||||
|
return _zero
|
||||||
|
if game.get("gameType", 2) == 3:
|
||||||
|
return _playoff_importance(game)
|
||||||
|
if game.get("gameType", 2) != 2:
|
||||||
|
return _zero
|
||||||
|
|
||||||
|
home_st = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||||
|
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||||
|
|
||||||
|
# Season weight — near-zero before game 30, sharp ramp 55-70, max at 82
|
||||||
|
avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2
|
||||||
|
if avg_gp <= 30:
|
||||||
|
season_weight = 0.05
|
||||||
|
else:
|
||||||
|
t = (avg_gp - 30) / (82 - 30)
|
||||||
|
season_weight = min(t**1.8, 1.0)
|
||||||
|
|
||||||
|
# Playoff relevance — peaks for bubble teams (wildcard rank ~17-19)
|
||||||
|
best_wc = min(
|
||||||
|
home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32
|
||||||
|
)
|
||||||
|
if best_wc <= 12:
|
||||||
|
playoff_relevance = 0.60
|
||||||
|
elif best_wc <= 16:
|
||||||
|
playoff_relevance = 0.85
|
||||||
|
elif best_wc <= 19:
|
||||||
|
playoff_relevance = 1.00
|
||||||
|
elif best_wc <= 23:
|
||||||
|
playoff_relevance = 0.65
|
||||||
|
else:
|
||||||
|
playoff_relevance = 0.15
|
||||||
|
|
||||||
|
# Division/conference rivalry multiplier
|
||||||
|
home_div = home_st["division_abbrev"]
|
||||||
|
away_div = away_st["division_abbrev"]
|
||||||
|
home_conf = home_st["conference_abbrev"]
|
||||||
|
away_conf = away_st["conference_abbrev"]
|
||||||
|
if home_div and away_div and home_div == away_div:
|
||||||
|
rivalry_multiplier = 1.4
|
||||||
|
elif home_conf and away_conf and home_conf == away_conf:
|
||||||
|
rivalry_multiplier = 1.2
|
||||||
|
else:
|
||||||
|
rivalry_multiplier = 1.0
|
||||||
|
|
||||||
|
raw = season_weight * playoff_relevance * rivalry_multiplier
|
||||||
|
importance = max(0, min(int((raw / 1.4) * 150), 150))
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"importance components — season_weight: %.3f, playoff_relevance: %.2f, "
|
||||||
|
"rivalry: %.1f, importance: %s",
|
||||||
|
season_weight,
|
||||||
|
playoff_relevance,
|
||||||
|
rivalry_multiplier,
|
||||||
|
importance,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"season_weight": round(season_weight, 3),
|
||||||
|
"playoff_relevance": playoff_relevance,
|
||||||
|
"rivalry": rivalry_multiplier,
|
||||||
|
"total": importance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_game_importance(game):
|
||||||
|
return _importance_components(game)["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def utc_to_eastern(utc_time):
|
||||||
|
utc_datetime = datetime.strptime(utc_time, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
eastern_datetime = utc_datetime.replace(tzinfo=timezone.utc).astimezone(EASTERN)
|
||||||
|
return eastern_datetime.strftime("%I:%M %p")
|
||||||
+50
-18
@@ -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
|
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():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template("index.html")
|
||||||
|
|
||||||
@app.route('/scoreboard')
|
|
||||||
|
@app.route("/scoreboard")
|
||||||
def get_scoreboard():
|
def get_scoreboard():
|
||||||
try:
|
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)
|
scoreboard_data = json.load(json_file)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({"error": "Failed to retrieve scoreboard data. File not found."})
|
return jsonify({"error": "Failed to retrieve scoreboard data. File not found."})
|
||||||
except json.JSONDecodeError:
|
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:
|
if scoreboard_data:
|
||||||
live_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "LIVE"]
|
games = parse_games(scoreboard_data)
|
||||||
pre_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "PRE"]
|
return jsonify(
|
||||||
final_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "FINAL"]
|
{
|
||||||
return jsonify({
|
"live_games": [
|
||||||
"live_games": live_games,
|
g
|
||||||
"pre_games": pre_games,
|
for g in games
|
||||||
"final_games": final_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:
|
else:
|
||||||
return jsonify({"error": "Failed to retrieve scoreboard data"})
|
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=23, minute=00, second=0, microsecond=0) # 7:00 PM EST
|
|
||||||
end_time_evening = now.replace(hour=8, minute=00, second=0, microsecond=0) # 3:00 AM EST
|
|
||||||
|
|
||||||
if now >= start_time_evening or now < end_time_evening:
|
|
||||||
# Use now URL
|
|
||||||
nhle_api_url = "https://api-web.nhle.com/v1/score/now"
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Use current data URL
|
|
||||||
nhle_api_url = f"https://api-web.nhle.com/v1/score/{now.strftime('%Y-%m-%d')}"
|
|
||||||
|
|
||||||
response = requests.get(nhle_api_url)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json()
|
|
||||||
else:
|
|
||||||
print("Error:", response.status_code)
|
|
||||||
|
|
||||||
# Store scoreboard data locally
|
|
||||||
def store_scoreboard_data():
|
|
||||||
scoreboard_data = get_scoreboard_data()
|
|
||||||
if scoreboard_data:
|
|
||||||
with open(SCOREBOARD_DATA_FILE, 'w') as json_file:
|
|
||||||
json.dump(scoreboard_data, json_file)
|
|
||||||
return scoreboard_data
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
def extract_game_info(scoreboard_data):
|
|
||||||
if not scoreboard_data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
extracted_info = []
|
|
||||||
for game in scoreboard_data.get("games", []):
|
|
||||||
game_state = convert_game_state(game["gameState"])
|
|
||||||
extracted_info.append({
|
|
||||||
"Home Team": game["homeTeam"]["name"]["default"],
|
|
||||||
"Home Score": game["homeTeam"]["score"] if game_state != "PRE" else "N/A",
|
|
||||||
"Away Team": game["awayTeam"]["name"]["default"],
|
|
||||||
"Away Score": game["awayTeam"]["score"] if game_state != "PRE" else "N/A",
|
|
||||||
"Home Logo": game["homeTeam"]["logo"],
|
|
||||||
"Away Logo": game["awayTeam"]["logo"],
|
|
||||||
"Game State": game_state,
|
|
||||||
"Period": process_period(game),
|
|
||||||
"Time Remaining": process_time_remaining(game),
|
|
||||||
"Time Running": game["clock"]["running"] if game_state == "LIVE" else "N/A",
|
|
||||||
"Intermission": game["clock"]["inIntermission"] if game_state == "LIVE" else "N/A",
|
|
||||||
"Priority": calculate_game_priority(game),
|
|
||||||
"Start Time": process_start_time(game),
|
|
||||||
"Home Record": game["homeTeam"]["record"] if game["gameState"] in ["PRE", "FUT"] else "N/A",
|
|
||||||
"Away Record": game["awayTeam"]["record"] if game["gameState"] in ["PRE", "FUT"] else "N/A",
|
|
||||||
"Home Shots": game["homeTeam"]["sog"] if game["gameState"] not in ["PRE", "FUT"] else 0,
|
|
||||||
"Away Shots": game["awayTeam"]["sog"] if game["gameState"] not in ["PRE", "FUT"] else 0,
|
|
||||||
"Home Power Play": get_power_play_info(game, game["homeTeam"]["name"]["default"]),
|
|
||||||
"Away Power Play": get_power_play_info(game, game["awayTeam"]["name"]["default"]),
|
|
||||||
"Last Period Type": get_game_outcome(game, game_state)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort games based on priority
|
|
||||||
return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True)
|
|
||||||
|
|
||||||
def convert_game_state(game_state):
|
|
||||||
state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"}
|
|
||||||
return state_mapping.get(game_state, game_state)
|
|
||||||
|
|
||||||
def process_period(game):
|
|
||||||
if game["gameState"] in ["PRE", "FUT"]:
|
|
||||||
return 0
|
|
||||||
elif game["gameState"] in ["FINAL", "OFF"]:
|
|
||||||
return "N/A"
|
|
||||||
else:
|
|
||||||
return game["periodDescriptor"]["number"]
|
|
||||||
|
|
||||||
def process_time_remaining(game):
|
|
||||||
if game["gameState"] in ["PRE", "FUT"]:
|
|
||||||
return "20:00"
|
|
||||||
elif game["gameState"] in ["FINAL", "OFF"]:
|
|
||||||
return "00:00"
|
|
||||||
else:
|
|
||||||
time_remaining = game["clock"]["timeRemaining"]
|
|
||||||
return "END" if time_remaining == "00:00" else time_remaining
|
|
||||||
|
|
||||||
def process_start_time(game):
|
|
||||||
if game["gameState"] in ["PRE", "FUT"]:
|
|
||||||
utc_time = game["startTimeUTC"]
|
|
||||||
return utc_to_est_time(utc_time)
|
|
||||||
else:
|
|
||||||
return "N/A"
|
|
||||||
|
|
||||||
def get_power_play_info(game, team_name):
|
|
||||||
if "situation" in game and "situationDescriptions" in game["situation"]:
|
|
||||||
for situation in game["situation"]["situationDescriptions"]:
|
|
||||||
if situation == "PP" and game["awayTeam"]["name"]["default"] == team_name:
|
|
||||||
return f"PP {game['situation']['timeRemaining']}"
|
|
||||||
elif situation == "PP" and game["homeTeam"]["name"]["default"] == team_name:
|
|
||||||
return f"PP {game['situation']['timeRemaining']}"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_game_outcome(game, game_state):
|
|
||||||
return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A"
|
|
||||||
|
|
||||||
def calculate_game_priority(game):
|
|
||||||
# Return 0 if game is in certain states
|
|
||||||
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"] or game["clock"]["inIntermission"]:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 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_adjustment = home_total + away_total
|
|
||||||
|
|
||||||
# Get period, time remaining, scores, and other relevant data
|
|
||||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
|
||||||
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
|
|
||||||
home_score = game["homeTeam"]["score"]
|
|
||||||
away_score = game["awayTeam"]["score"]
|
|
||||||
score_difference = abs(home_score - away_score)
|
|
||||||
score_total = (home_score + away_score) * 25
|
|
||||||
|
|
||||||
# Calculate the base priority based on period
|
|
||||||
base_priority = {5: 650, 4: 600, 3: 300, 2: 200}.get(period, 100)
|
|
||||||
|
|
||||||
# Adjust base priority based on score difference
|
|
||||||
if score_difference > 3:
|
|
||||||
base_priority -= 500
|
|
||||||
elif score_difference > 2:
|
|
||||||
base_priority -= 350
|
|
||||||
elif score_difference > 1:
|
|
||||||
base_priority -= 100
|
|
||||||
|
|
||||||
# Adjust base priority based on certain conditions
|
|
||||||
if score_difference == 0 and period == 3 and time_remaining <= 600:
|
|
||||||
base_priority += 100
|
|
||||||
|
|
||||||
# Calculate time priority
|
|
||||||
time_multiplier = {4: 2, 3: 2, 2: 1.5}.get(period, 0.75)
|
|
||||||
|
|
||||||
time_priority = ((1200 - time_remaining) / 20) * time_multiplier
|
|
||||||
|
|
||||||
# Calculate the final priority
|
|
||||||
final_priority = int(base_priority + time_priority - matchup_adjustment + score_total)
|
|
||||||
|
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+220
-219
@@ -1,243 +1,244 @@
|
|||||||
// Function to fetch scoreboard data using AJAX
|
async function fetchScoreboardData() {
|
||||||
function fetchScoreboardData() {
|
try {
|
||||||
var xhr = new XMLHttpRequest();
|
const res = await fetch('/scoreboard');
|
||||||
xhr.open("GET", "/scoreboard", true);
|
if (!res.ok) throw new Error(res.status);
|
||||||
xhr.onreadystatechange = function () {
|
updateScoreboard(await res.json());
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
} catch (e) {
|
||||||
if (xhr.status === 200) {
|
console.error('Failed to fetch scoreboard data:', e);
|
||||||
updateScoreboard(JSON.parse(xhr.responseText));
|
}
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch scoreboard data.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to update scoreboard with fetched data
|
|
||||||
function updateScoreboard(data) {
|
function updateScoreboard(data) {
|
||||||
var liveGamesSection = document.getElementById('live-games-section');
|
const sections = [
|
||||||
var preGamesSection = document.getElementById('pre-games-section');
|
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_games, render: renderLiveGame },
|
||||||
var finalGamesSection = document.getElementById('final-games-section');
|
{ 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) {
|
for (const { sectionId, gridId, games, render } of sections) {
|
||||||
var liveGamesExist = data && data.live_games && data.live_games.length > 0;
|
const section = document.getElementById(sectionId);
|
||||||
if (liveGamesExist) {
|
const grid = document.getElementById(gridId);
|
||||||
if (!document.getElementById('live-games')) {
|
const hasGames = games && games.length > 0;
|
||||||
var targetElement = document.getElementById('live-games-section');
|
section.classList.toggle('hidden', !hasGames);
|
||||||
var newElement = document.createElement('h1');
|
|
||||||
newElement.setAttribute('id', 'live-games');
|
// Snapshot current clock state before blowing away the DOM
|
||||||
newElement.innerText = 'Live Games';
|
const clockSnapshot = snapshotClocks(grid);
|
||||||
targetElement.parentNode.insertBefore(newElement, targetElement);
|
|
||||||
}
|
grid.innerHTML = hasGames ? games.map(render).join('') : '';
|
||||||
liveGamesSection.innerHTML = generateGameBoxes(data.live_games, 'LIVE');
|
|
||||||
} else {
|
// Restore smooth local anchors unless we're in the final 60s
|
||||||
var liveGamesElement = document.getElementById('live-games');
|
if (hasGames) restoreClocks(grid, clockSnapshot);
|
||||||
if (liveGamesElement) {
|
|
||||||
liveGamesElement.remove();
|
|
||||||
}
|
|
||||||
liveGamesSection.innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preGamesSection) {
|
updateGauges();
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGauge() {
|
// ── Renderers ────────────────────────────────────────
|
||||||
document.querySelectorAll('.gauge').forEach(function(gauge) {
|
|
||||||
// Get the score value from the data-score attribute
|
|
||||||
var score = parseInt(gauge.getAttribute('data-score'));
|
|
||||||
|
|
||||||
// Clamp the score value between 0 and 700
|
function renderLiveGame(game) {
|
||||||
score = Math.min(700, Math.max(0, score));
|
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
|
const periodLabel = intermission
|
||||||
var gaugeWidth = (score / 700) * 100;
|
? `<span class="badge">${intermissionLabel(period)}</span>`
|
||||||
|
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
|
||||||
|
|
||||||
// Set the width of the gauge
|
const dot = running ? `<span class="live-dot"></span>` : '';
|
||||||
gauge.style.width = gaugeWidth + '%';
|
|
||||||
|
|
||||||
if (score <=350) {
|
// Tick the clock locally when the clock is running or during intermission
|
||||||
gauge.style.backgroundColor = '#4A90E2'
|
const shouldTick = running || intermission;
|
||||||
} else if (score <= 560) {
|
const rawSeconds = timeToSeconds(time);
|
||||||
gauge.style.backgroundColor = '#FF4500'
|
const clockAttrs = shouldTick
|
||||||
} else {
|
? `data-seconds="${rawSeconds}" data-received-at="${Date.now()}"`
|
||||||
gauge.style.backgroundColor = '#FF0033'
|
: '';
|
||||||
|
|
||||||
|
const hype = !intermission ? `
|
||||||
|
<div class="hype-meter">
|
||||||
|
<span class="hype-label">Hype Meter</span>
|
||||||
|
<div class="gauge-track">
|
||||||
|
<div class="gauge" data-score="${game['Priority']}"></div>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="badges">
|
||||||
|
${periodLabel}
|
||||||
|
<span class="badge" ${clockAttrs}>${time}</span>
|
||||||
|
${ppBadge(game)}
|
||||||
|
</div>
|
||||||
|
${dot}
|
||||||
|
</div>
|
||||||
|
${teamRow(game, 'Away', 'live')}
|
||||||
|
${teamRow(game, 'Home', 'live')}
|
||||||
|
${hype}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreGame(game) {
|
||||||
|
return `
|
||||||
|
<div class="game-box">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge">${game['Start Time']}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${teamRow(game, 'Away', 'pre')}
|
||||||
|
${teamRow(game, 'Home', 'pre')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFinalGame(game) {
|
||||||
|
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
|
||||||
|
const label = labels[game['Last Period Type']] ?? 'Final';
|
||||||
|
return `
|
||||||
|
<div class="game-box">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge badge-muted">${label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${teamRow(game, 'Away', 'final')}
|
||||||
|
${teamRow(game, 'Home', 'final')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team Row ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function teamRow(game, side, state) {
|
||||||
|
const name = game[`${side} Team`];
|
||||||
|
const logo = game[`${side} Logo`];
|
||||||
|
const score = game[`${side} Score`];
|
||||||
|
const sog = game[`${side} Shots`];
|
||||||
|
const record = game[`${side} Record`];
|
||||||
|
|
||||||
|
const sogHtml = (state === 'live' || state === 'final') && sog !== undefined
|
||||||
|
? `<span class="team-sog">${sog} SOG</span>` : '';
|
||||||
|
|
||||||
|
const right = state === 'pre'
|
||||||
|
? `<span class="team-record">${record}</span>`
|
||||||
|
: `<span class="team-score">${score}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="team-row">
|
||||||
|
<img src="${logo}" alt="${name} logo" class="team-logo">
|
||||||
|
<div class="team-meta">
|
||||||
|
<span class="team-name">${name}</span>
|
||||||
|
${sogHtml}
|
||||||
|
</div>
|
||||||
|
${right}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppBadge(game) {
|
||||||
|
const awayPP = game['Away Power Play'];
|
||||||
|
const homePP = game['Home Power Play'];
|
||||||
|
const pp = awayPP || homePP;
|
||||||
|
if (!pp) return '';
|
||||||
|
|
||||||
|
const team = awayPP ? game['Away Team'] : game['Home Team'];
|
||||||
|
const timeStr = pp.replace('PP ', '');
|
||||||
|
const seconds = timeToSeconds(timeStr);
|
||||||
|
const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`;
|
||||||
|
|
||||||
|
return `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gauge ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateGauges() {
|
||||||
|
document.querySelectorAll('.gauge').forEach(el => {
|
||||||
|
const score = Math.min(1000, Math.max(0, parseInt(el.dataset.score, 10)));
|
||||||
|
el.style.width = `${(score / 1000) * 100}%`;
|
||||||
|
el.style.backgroundColor = score <= 350 ? '#4a90e2'
|
||||||
|
: score <= 650 ? '#f97316'
|
||||||
|
: '#ef4444';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOCK_SYNC_THRESHOLD = 60; // seconds — only resync from API in final 60s
|
||||||
|
|
||||||
|
// ── Clock ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeToSeconds(str) {
|
||||||
|
if (!str || str === 'END') return 0;
|
||||||
|
const [m, s] = str.split(':').map(Number);
|
||||||
|
return m * 60 + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToTime(s) {
|
||||||
|
if (s <= 0) return 'END';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotClocks(grid) {
|
||||||
|
const snapshot = new Map();
|
||||||
|
grid.querySelectorAll('[data-game-key]').forEach(card => {
|
||||||
|
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
|
||||||
|
if (!badge) return;
|
||||||
|
const seconds = parseInt(badge.dataset.seconds, 10);
|
||||||
|
const receivedAt = parseInt(badge.dataset.receivedAt, 10);
|
||||||
|
const elapsed = Math.floor((Date.now() - receivedAt) / 1000);
|
||||||
|
const current = Math.max(0, seconds - elapsed);
|
||||||
|
snapshot.set(card.dataset.gameKey, { current, ts: Date.now() });
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreClocks(grid, snapshot) {
|
||||||
|
grid.querySelectorAll('[data-game-key]').forEach(card => {
|
||||||
|
const prior = snapshot.get(card.dataset.gameKey);
|
||||||
|
if (!prior) return;
|
||||||
|
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
|
||||||
|
if (!badge) return;
|
||||||
|
// Only restore if we're outside the final sync window
|
||||||
|
if (prior.current > CLOCK_SYNC_THRESHOLD) {
|
||||||
|
badge.dataset.seconds = prior.current;
|
||||||
|
badge.dataset.receivedAt = prior.ts;
|
||||||
|
badge.textContent = secondsToTime(prior.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to generate HTML for game boxes
|
function tickClocks() {
|
||||||
function generateGameBoxes(games, state) {
|
const now = Date.now();
|
||||||
var html = '';
|
document.querySelectorAll('[data-seconds][data-received-at]').forEach(el => {
|
||||||
games.forEach(function(game) {
|
const seconds = parseInt(el.dataset.seconds, 10);
|
||||||
if (game['Game State'] === state) {
|
const receivedAt = parseInt(el.dataset.receivedAt, 10);
|
||||||
html += '<div class="game-box">';
|
const elapsed = Math.floor((now - receivedAt) / 1000);
|
||||||
if (state === 'LIVE') {
|
el.textContent = secondsToTime(Math.max(0, seconds - elapsed));
|
||||||
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>';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
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() {
|
function autoRefresh() {
|
||||||
fetchScoreboardData();
|
fetchScoreboardData();
|
||||||
setTimeout(autoRefresh, 5000); // 20 seconds
|
setTimeout(autoRefresh, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the autoRefresh function when the page loads
|
window.addEventListener('load', () => {
|
||||||
window.onload = function() {
|
|
||||||
autoRefresh();
|
autoRefresh();
|
||||||
};
|
setInterval(tickClocks, 1000);
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||||
|
console.warn('Service worker registration failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+275
-299
@@ -1,378 +1,354 @@
|
|||||||
body {
|
:root {
|
||||||
background-color: #121212;
|
--bg: #111;
|
||||||
font-family: Arial, sans-serif;
|
--card: #1c1c1c;
|
||||||
color: #fff;
|
--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;
|
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;
|
text-align: center;
|
||||||
margin-top: 15px;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
color: #f2f2f2;
|
|
||||||
font-size: 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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-around;
|
gap: var(--gap);
|
||||||
margin-top: 20px;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Game Card ──────────────────────────────────── */
|
||||||
|
|
||||||
.game-box {
|
.game-box {
|
||||||
background-color: #333;
|
background: var(--card);
|
||||||
border-radius: 12px;
|
border: 1px solid var(--card-border);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
border-radius: var(--radius);
|
||||||
padding: 1%;
|
padding: 1rem 1rem 0.875rem;
|
||||||
width: 16%;
|
width: var(--card-w);
|
||||||
max-width: 300px;
|
flex-shrink: 0;
|
||||||
position: relative;
|
border-top-width: 3px;
|
||||||
margin-left: 1%;
|
|
||||||
margin-right: 1%;
|
|
||||||
margin-bottom: 1.15%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 2%;
|
justify-content: space-between;
|
||||||
margin-top: 9%;
|
margin-bottom: 0.5rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-info-column {
|
.badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-logo {
|
.badge {
|
||||||
width: 18%;
|
font-size: 0.65rem;
|
||||||
height: auto;
|
font-weight: 700;
|
||||||
margin-right: 2.25%;
|
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 {
|
.badge-live {
|
||||||
font-size: 90%;
|
background: var(--green-bg);
|
||||||
font-weight: bold;
|
color: var(--green-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add a media query for screens between 769px and 900px */
|
.badge-muted {
|
||||||
@media only screen and (max-width: 950px) and (min-width: 769px) {
|
color: var(--text-muted);
|
||||||
.team-name {
|
|
||||||
font-size: 0.55rem; /* Adjusted font size for screens between 769px and 900px */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-score {
|
|
||||||
font-size: 130%;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-record {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
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: 65%;
|
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-dot {
|
.live-dot {
|
||||||
position: absolute;
|
width: 7px;
|
||||||
top: 5px;
|
height: 7px;
|
||||||
right: 5px;
|
background: var(--red);
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background-color: red;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: pulse 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre-state {
|
@keyframes pulse {
|
||||||
position: absolute;
|
0%, 100% { opacity: 1; }
|
||||||
top: 5%;
|
50% { opacity: 0.4; }
|
||||||
left: 3%;
|
}
|
||||||
background-color: #444;
|
|
||||||
padding: 1.5%;
|
/* ── Team Rows ──────────────────────────────────── */
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.65rem;
|
.team-row {
|
||||||
color: #fff;
|
|
||||||
font-weight: bolder;
|
|
||||||
z-index: 1;
|
|
||||||
width: auto;
|
|
||||||
height: 10%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.55rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add a media query for screens between 769px and 900px */
|
.team-row + .team-row {
|
||||||
@media only screen and (max-width: 950px) and (min-width: 769px) {
|
border-top: 1px solid var(--card-border);
|
||||||
.pre-state {
|
|
||||||
font-size: 0.55rem; /* Adjusted font size for screens between 769px and 900px */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.final-state {
|
.team-logo {
|
||||||
position: absolute;
|
width: 40px;
|
||||||
top: 5%;
|
height: 40px;
|
||||||
left: 3%;
|
object-fit: contain;
|
||||||
background-color: #444;
|
flex-shrink: 0;
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 65%;
|
|
||||||
color: #ddd;
|
|
||||||
z-index: 1;
|
|
||||||
font-weight: bold;
|
|
||||||
width: auto;
|
|
||||||
height: 7.5%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-state {
|
.team-meta {
|
||||||
position: absolute;
|
flex: 1;
|
||||||
top: 4%;
|
min-width: 0;
|
||||||
left: 4%;
|
|
||||||
background-color: #0b6e31;
|
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 80%;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: bolder;
|
|
||||||
z-index: 1;
|
|
||||||
width: 7.2%;
|
|
||||||
height: 7.5%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-time {
|
|
||||||
position: absolute;
|
|
||||||
top: 4%;
|
|
||||||
left: 16%;
|
|
||||||
background-color: #444;
|
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 80%;
|
|
||||||
color: #ddd;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
width: 10%;
|
|
||||||
height: 7.5%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-state-intermission {
|
|
||||||
position: absolute;
|
|
||||||
top: 5%;
|
|
||||||
left: 4%;
|
|
||||||
background-color: #444;
|
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 80%;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: bolder;
|
|
||||||
z-index: 1;
|
|
||||||
width: 13%;
|
|
||||||
height: 7.5%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-evenly;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-time-intermission {
|
.team-name {
|
||||||
position: absolute;
|
font-size: 0.825rem;
|
||||||
top: 5%;
|
font-weight: 600;
|
||||||
left: 21.5%;
|
white-space: nowrap;
|
||||||
background-color: #444;
|
overflow: hidden;
|
||||||
padding: 1.5%;
|
text-overflow: ellipsis;
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 80%;
|
|
||||||
color: #ddd;
|
|
||||||
z-index: 1;
|
|
||||||
width: 12.5%;
|
|
||||||
height: 7.5%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#live-games-section {
|
.team-sog {
|
||||||
display: flex;
|
font-size: 0.68rem;
|
||||||
align-items: start;
|
color: var(--text-muted);
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pre-games-section {
|
.team-pp {
|
||||||
display: flex;
|
font-size: 0.68rem;
|
||||||
align-items: start;
|
color: var(--red);
|
||||||
flex-wrap: wrap;
|
font-weight: 600;
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#final-games-section {
|
.team-score {
|
||||||
display: flex;
|
font-size: 1.6rem;
|
||||||
align-items: start;
|
font-weight: 700;
|
||||||
flex-wrap: wrap;
|
margin-left: auto;
|
||||||
justify-content: flex-start;
|
flex-shrink: 0;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add styles for the game score gauge */
|
.team-record {
|
||||||
.game-score-gauge {
|
font-size: 0.72rem;
|
||||||
height: 1%;
|
color: var(--text-muted);
|
||||||
background-color: #ccc;
|
margin-left: auto;
|
||||||
border-radius: 5px;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Power Play Badge (inline in card header) ─── */
|
||||||
|
|
||||||
|
.badge-pp {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hype Meter ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.hype-meter {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hype-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-track {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge {
|
.gauge {
|
||||||
height: 10px; /* Adjust height as needed */
|
height: 100%;
|
||||||
/*#8A2BE2*/
|
border-radius: 99px;
|
||||||
/*#6699CC*/
|
width: 0;
|
||||||
|
transition: width 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add media query for smaller screens */
|
/* ── Desktop ────────────────────────────────────── */
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.scoreboard {
|
@media (min-width: 900px) {
|
||||||
flex-direction: column; /* Change direction to column for smaller screens */
|
:root {
|
||||||
align-items: center; /* Center align items */
|
--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 {
|
.game-box {
|
||||||
width: 90%;
|
padding: 1.125rem 1.125rem 1rem;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-logo {
|
.team-logo {
|
||||||
width: 12%;
|
width: 48px;
|
||||||
height: auto;
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-name {
|
.team-name {
|
||||||
font-size: 100%;
|
font-size: 0.95rem;
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score {
|
.team-score {
|
||||||
font-size: 140%;
|
font-size: 1.9rem;
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-sog {
|
.hype-label {
|
||||||
font-size: 70%;
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
:root {
|
||||||
|
--card-w: 400px;
|
||||||
|
--gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-info {
|
main {
|
||||||
font-size: 90%;
|
padding: 1.25rem 2.5rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-state {
|
.header-title {
|
||||||
top: 5%;
|
font-size: 2.8rem;
|
||||||
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 {
|
.section-heading {
|
||||||
top: 5%;
|
font-size: 0.95rem;
|
||||||
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 {
|
.game-box {
|
||||||
top: 5%;
|
padding: 1.25rem 1.25rem 1.125rem;
|
||||||
left: 3.5%;
|
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 72%;
|
|
||||||
width: auto;
|
|
||||||
height: 7.5%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre-state {
|
.team-logo {
|
||||||
top: 5%;
|
width: 56px;
|
||||||
left: 3.5%;
|
height: 56px;
|
||||||
padding: 1.5%;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 80%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
<head>
|
||||||
<title>NHL Scoreboard</title>
|
<title>NHL Scoreboard</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="live-games-section"></div>
|
<header>
|
||||||
<div id="pre-games-section"></div>
|
<span class="header-title">NHL Scoreboard</span>
|
||||||
<div id="final-games-section"></div>
|
</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>
|
<script src="/static/script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
Flask==3.1.0
|
||||||
Jinja2==3.1.3
|
Jinja2==3.1.4
|
||||||
requests==2.31.0
|
requests==2.32.3
|
||||||
Werkzeug==3.0.1
|
Werkzeug==3.1.3
|
||||||
waitress==3.0.0
|
waitress==3.0.1
|
||||||
schedule==1.2.1
|
schedule==1.2.2
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from app import app
|
|
||||||
from waitress import serve
|
|
||||||
import threading
|
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__':
|
from waitress import serve
|
||||||
store_scoreboard_data()
|
|
||||||
update_nhl_standings()
|
from app import app
|
||||||
threading.Thread(target=schedule_tasks).start()
|
from app.api import refresh_scores
|
||||||
serve(app, host="0.0.0.0", port=2897)
|
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,889 @@
|
|||||||
|
import app.games
|
||||||
|
from tests.conftest import make_game
|
||||||
|
from app.games import (
|
||||||
|
_get_man_advantage,
|
||||||
|
calculate_game_importance,
|
||||||
|
calculate_game_priority,
|
||||||
|
convert_game_state,
|
||||||
|
format_record,
|
||||||
|
get_comeback_bonus,
|
||||||
|
get_game_outcome,
|
||||||
|
get_period,
|
||||||
|
get_power_play_info,
|
||||||
|
get_start_time,
|
||||||
|
get_time_remaining,
|
||||||
|
parse_games,
|
||||||
|
utc_to_eastern,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertGameState:
|
||||||
|
def test_off_maps_to_final(self):
|
||||||
|
assert convert_game_state("OFF") == "FINAL"
|
||||||
|
|
||||||
|
def test_crit_maps_to_live(self):
|
||||||
|
assert convert_game_state("CRIT") == "LIVE"
|
||||||
|
|
||||||
|
def test_fut_maps_to_pre(self):
|
||||||
|
assert convert_game_state("FUT") == "PRE"
|
||||||
|
|
||||||
|
def test_unknown_state_passes_through(self):
|
||||||
|
assert convert_game_state("LIVE") == "LIVE"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessRecord:
|
||||||
|
def test_na_returns_na(self):
|
||||||
|
assert format_record("N/A") == "N/A"
|
||||||
|
|
||||||
|
def test_pads_single_digit_parts(self):
|
||||||
|
assert format_record("5-3-1") == "05-03-01"
|
||||||
|
|
||||||
|
def test_already_padded_unchanged(self):
|
||||||
|
assert format_record("40-25-10") == "40-25-10"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessPeriod:
|
||||||
|
def test_pre_game_returns_zero(self):
|
||||||
|
game = make_game(game_state="PRE")
|
||||||
|
assert get_period(game) == 0
|
||||||
|
|
||||||
|
def test_fut_game_returns_zero(self):
|
||||||
|
game = make_game(game_state="FUT")
|
||||||
|
assert get_period(game) == 0
|
||||||
|
|
||||||
|
def test_final_game_returns_na(self):
|
||||||
|
game = make_game(game_state="OFF")
|
||||||
|
assert get_period(game) == "N/A"
|
||||||
|
|
||||||
|
def test_live_game_returns_period_number(self):
|
||||||
|
game = make_game(game_state="LIVE", period=2)
|
||||||
|
assert get_period(game) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessTimeRemaining:
|
||||||
|
def test_pre_game_returns_2000(self):
|
||||||
|
game = make_game(game_state="FUT")
|
||||||
|
assert get_time_remaining(game) == "20:00"
|
||||||
|
|
||||||
|
def test_final_game_returns_0000(self):
|
||||||
|
game = make_game(game_state="OFF")
|
||||||
|
assert get_time_remaining(game) == "00:00"
|
||||||
|
|
||||||
|
def test_live_game_returns_clock(self):
|
||||||
|
game = make_game(game_state="LIVE", seconds_remaining=305)
|
||||||
|
assert get_time_remaining(game) == "05:05"
|
||||||
|
|
||||||
|
def test_live_game_at_zero_returns_end(self):
|
||||||
|
game = make_game(game_state="LIVE", seconds_remaining=0)
|
||||||
|
assert get_time_remaining(game) == "END"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessStartTime:
|
||||||
|
def test_pre_game_returns_est_time(self):
|
||||||
|
game = make_game(game_state="FUT", start_time_utc="2024-04-10T23:00:00Z")
|
||||||
|
result = get_start_time(game)
|
||||||
|
assert result == "7:00 PM"
|
||||||
|
|
||||||
|
def test_pre_game_strips_leading_zero(self):
|
||||||
|
game = make_game(game_state="FUT", start_time_utc="2024-04-10T22:00:00Z")
|
||||||
|
result = get_start_time(game)
|
||||||
|
assert not result.startswith("0")
|
||||||
|
|
||||||
|
def test_live_game_returns_na(self):
|
||||||
|
game = make_game(game_state="LIVE")
|
||||||
|
assert get_start_time(game) == "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetGameOutcome:
|
||||||
|
def test_final_game_returns_last_period_type(self):
|
||||||
|
game = make_game(game_state="OFF")
|
||||||
|
assert get_game_outcome(game, "FINAL") == "REG"
|
||||||
|
|
||||||
|
def test_live_game_returns_na(self):
|
||||||
|
game = make_game(game_state="LIVE")
|
||||||
|
assert get_game_outcome(game, "LIVE") == "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtcToEstTime:
|
||||||
|
def test_converts_utc_to_edt(self):
|
||||||
|
# April is EDT (UTC-4): 23:00 UTC → 07:00 PM EDT
|
||||||
|
result = utc_to_eastern("2024-04-10T23:00:00Z")
|
||||||
|
assert result == "07:00 PM"
|
||||||
|
|
||||||
|
def test_converts_utc_to_est(self):
|
||||||
|
# January is EST (UTC-5): 23:00 UTC → 06:00 PM EST
|
||||||
|
result = utc_to_eastern("2024-01-15T23:00:00Z")
|
||||||
|
assert result == "06:00 PM"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseGames:
|
||||||
|
def test_returns_empty_list_for_none(self):
|
||||||
|
assert parse_games(None) == []
|
||||||
|
|
||||||
|
def test_returns_empty_list_for_empty_dict(self):
|
||||||
|
assert parse_games({}) == []
|
||||||
|
|
||||||
|
def test_pre_games_sorted_by_start_time_ascending(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={
|
||||||
|
"league_sequence": 16,
|
||||||
|
"league_l10_sequence": 16,
|
||||||
|
"division_abbrev": "ATL",
|
||||||
|
"conference_abbrev": "E",
|
||||||
|
"games_played": 40,
|
||||||
|
"wildcard_sequence": 16,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
late = make_game(
|
||||||
|
game_state="FUT",
|
||||||
|
home_name="Rangers",
|
||||||
|
away_name="Devils",
|
||||||
|
start_time_utc="2024-04-10T22:00:00Z",
|
||||||
|
)
|
||||||
|
early = make_game(
|
||||||
|
game_state="FUT",
|
||||||
|
home_name="Bruins",
|
||||||
|
away_name="Canadiens",
|
||||||
|
start_time_utc="2024-04-10T19:00:00Z",
|
||||||
|
)
|
||||||
|
next_day = make_game(
|
||||||
|
game_state="FUT",
|
||||||
|
home_name="Kings",
|
||||||
|
away_name="Ducks",
|
||||||
|
start_time_utc="2024-04-11T00:30:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = parse_games({"games": [late, next_day, early]})
|
||||||
|
pre_games = [g for g in result if g["Game State"] == "PRE"]
|
||||||
|
names = [g["Home Team"] for g in pre_games]
|
||||||
|
assert names == ["Bruins", "Rangers", "Kings"]
|
||||||
|
|
||||||
|
def test_live_games_still_sorted_by_priority_descending(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={
|
||||||
|
"league_sequence": 16,
|
||||||
|
"league_l10_sequence": 16,
|
||||||
|
"division_abbrev": "ATL",
|
||||||
|
"conference_abbrev": "E",
|
||||||
|
"games_played": 40,
|
||||||
|
"wildcard_sequence": 16,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Late P3 tied — high priority
|
||||||
|
high = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
home_name="Rangers",
|
||||||
|
away_name="Devils",
|
||||||
|
home_score=2,
|
||||||
|
away_score=2,
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=60,
|
||||||
|
)
|
||||||
|
# Early P1 blowout — low priority
|
||||||
|
low = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
home_name="Bruins",
|
||||||
|
away_name="Canadiens",
|
||||||
|
home_score=5,
|
||||||
|
away_score=0,
|
||||||
|
period=1,
|
||||||
|
seconds_remaining=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = parse_games({"games": [low, high]})
|
||||||
|
live_games = [g for g in result if g["Game State"] == "LIVE"]
|
||||||
|
assert live_games[0]["Home Team"] == "Rangers"
|
||||||
|
assert live_games[1]["Home Team"] == "Bruins"
|
||||||
|
|
||||||
|
def test_pre_games_ignore_priority_even_if_nonzero(self, mocker):
|
||||||
|
# Give one team standings that maximize importance, another that minimize it
|
||||||
|
def fake_standings(name):
|
||||||
|
if name == "Bruins":
|
||||||
|
return {
|
||||||
|
"league_sequence": 1,
|
||||||
|
"league_l10_sequence": 1,
|
||||||
|
"division_abbrev": "ATL",
|
||||||
|
"conference_abbrev": "E",
|
||||||
|
"games_played": 80,
|
||||||
|
"wildcard_sequence": 18,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"league_sequence": 32,
|
||||||
|
"league_l10_sequence": 32,
|
||||||
|
"division_abbrev": "PAC",
|
||||||
|
"conference_abbrev": "W",
|
||||||
|
"games_played": 10,
|
||||||
|
"wildcard_sequence": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
mocker.patch("app.games.get_team_standings", side_effect=fake_standings)
|
||||||
|
|
||||||
|
# Bruins game starts later but will have higher importance
|
||||||
|
high_hype_late = make_game(
|
||||||
|
game_state="FUT",
|
||||||
|
home_name="Bruins",
|
||||||
|
away_name="Maple Leafs",
|
||||||
|
start_time_utc="2024-04-10T23:00:00Z",
|
||||||
|
)
|
||||||
|
low_hype_early = make_game(
|
||||||
|
game_state="FUT",
|
||||||
|
home_name="Kings",
|
||||||
|
away_name="Ducks",
|
||||||
|
start_time_utc="2024-04-10T19:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = parse_games({"games": [high_hype_late, low_hype_early]})
|
||||||
|
pre_games = [g for g in result if g["Game State"] == "PRE"]
|
||||||
|
# Lower-hype-but-earlier game must still appear first
|
||||||
|
assert pre_games[0]["Home Team"] == "Kings"
|
||||||
|
assert pre_games[1]["Home Team"] == "Bruins"
|
||||||
|
assert pre_games[1]["Priority"] > pre_games[0]["Priority"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPowerPlayInfo:
|
||||||
|
def test_returns_empty_when_no_situation(self):
|
||||||
|
game = make_game()
|
||||||
|
assert get_power_play_info(game, "Maple Leafs") == ""
|
||||||
|
|
||||||
|
def test_returns_pp_info_for_away_team(self):
|
||||||
|
game = make_game(away_name="Bruins")
|
||||||
|
game["situation"] = {
|
||||||
|
"awayTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"homeTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
}
|
||||||
|
assert get_power_play_info(game, "Bruins") == "PP 1:30"
|
||||||
|
|
||||||
|
def test_returns_pp_info_for_home_team(self):
|
||||||
|
game = make_game(home_name="Maple Leafs", away_name="Bruins")
|
||||||
|
game["situation"] = {
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "0:45",
|
||||||
|
}
|
||||||
|
assert get_power_play_info(game, "Maple Leafs") == "PP 0:45"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManAdvantage:
|
||||||
|
def test_standard_5v4(self):
|
||||||
|
# 1451: away 1G+4S=5, home 5S+1G=6 → advantage=1
|
||||||
|
assert _get_man_advantage({"situationCode": "1451"}) == 1
|
||||||
|
|
||||||
|
def test_5v3(self):
|
||||||
|
# 1351: away 1G+3S=4, home 5S+1G=6 → advantage=2
|
||||||
|
assert _get_man_advantage({"situationCode": "1351"}) == 2
|
||||||
|
|
||||||
|
def test_4v3(self):
|
||||||
|
# 1341: away 1G+3S=4, home 4S+1G=5 → advantage=1
|
||||||
|
assert _get_man_advantage({"situationCode": "1341"}) == 1
|
||||||
|
|
||||||
|
def test_even_strength(self):
|
||||||
|
# 1551: away 1G+5S=6, home 5S+1G=6 → advantage=0
|
||||||
|
assert _get_man_advantage({"situationCode": "1551"}) == 0
|
||||||
|
|
||||||
|
def test_missing_code_defaults_to_1(self):
|
||||||
|
assert _get_man_advantage({}) == 1
|
||||||
|
|
||||||
|
def test_invalid_code_defaults_to_1(self):
|
||||||
|
assert _get_man_advantage({"situationCode": "abc"}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyNetBonus:
|
||||||
|
def test_en_late_p3_adds_200(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
|
||||||
|
with_en = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=90,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||||
|
"awayTeam": {"situationDescriptions": []},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200
|
||||||
|
|
||||||
|
def test_en_mid_p3_adds_150(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=3, seconds_remaining=300)
|
||||||
|
with_en = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=300,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||||
|
"awayTeam": {"situationDescriptions": []},
|
||||||
|
"timeRemaining": "5:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150
|
||||||
|
|
||||||
|
def test_en_ot_adds_250(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||||
|
with_en = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=4,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["EN"]},
|
||||||
|
"awayTeam": {"situationDescriptions": []},
|
||||||
|
"timeRemaining": "10:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250
|
||||||
|
|
||||||
|
def test_en_stacks_with_pp(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=3, seconds_remaining=90)
|
||||||
|
with_both = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=90,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP", "EN"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
delta = calculate_game_priority(with_both) - calculate_game_priority(base)
|
||||||
|
# PP late P3 = 150, EN late P3 = 200, total = 350
|
||||||
|
assert delta == 350
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiManAdvantage:
|
||||||
|
def test_5v3_ot_pp_bonus_is_320(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||||
|
with_5v3 = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=4,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
"situationCode": "1351",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320
|
||||||
|
|
||||||
|
def test_standard_5v4_unchanged(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=4,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
"situationCode": "1451",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateGamePriority:
|
||||||
|
def _live_game(
|
||||||
|
self,
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=300,
|
||||||
|
home_score=2,
|
||||||
|
away_score=1,
|
||||||
|
in_intermission=False,
|
||||||
|
):
|
||||||
|
return make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=period,
|
||||||
|
seconds_remaining=seconds_remaining,
|
||||||
|
home_score=home_score,
|
||||||
|
away_score=away_score,
|
||||||
|
in_intermission=in_intermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_zero_for_final(self):
|
||||||
|
game = make_game(game_state="OFF")
|
||||||
|
assert calculate_game_priority(game) == 0
|
||||||
|
|
||||||
|
def test_returns_zero_for_pre(self):
|
||||||
|
game = make_game(game_state="FUT")
|
||||||
|
assert calculate_game_priority(game) == 0
|
||||||
|
|
||||||
|
def test_intermission_returns_negative(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(in_intermission=True, seconds_remaining=0)
|
||||||
|
assert calculate_game_priority(game) < 0
|
||||||
|
|
||||||
|
def test_score_diff_greater_than_3(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(home_score=5, away_score=0)
|
||||||
|
result = calculate_game_priority(game)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_score_diff_greater_than_2(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(home_score=4, away_score=1)
|
||||||
|
result = calculate_game_priority(game)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_score_diff_greater_than_1(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(home_score=3, away_score=1)
|
||||||
|
result = calculate_game_priority(game)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_late_3rd_tied_bonus(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(
|
||||||
|
period=3, seconds_remaining=600, home_score=2, away_score=2
|
||||||
|
)
|
||||||
|
result = calculate_game_priority(game)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_final_6_minutes_tied_bonus(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
game = self._live_game(
|
||||||
|
period=3, seconds_remaining=300, home_score=2, away_score=2
|
||||||
|
)
|
||||||
|
result = calculate_game_priority(game)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_playoff_ot_escalates_per_period(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=600, game_type=3)
|
||||||
|
p5 = make_game(game_state="LIVE", period=5, seconds_remaining=600, game_type=3)
|
||||||
|
assert calculate_game_priority(p5) > calculate_game_priority(p4)
|
||||||
|
|
||||||
|
def test_shootout_ranks_below_late_ot(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
ot = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
||||||
|
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
|
||||||
|
# Sudden-death OT is more exciting than a skills competition
|
||||||
|
assert calculate_game_priority(ot) > calculate_game_priority(so)
|
||||||
|
|
||||||
|
def test_shootout_ranks_above_p2_blowout(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
so = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
|
||||||
|
blowout = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=2,
|
||||||
|
seconds_remaining=600,
|
||||||
|
home_score=5,
|
||||||
|
away_score=1,
|
||||||
|
game_type=2,
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(so) > calculate_game_priority(blowout)
|
||||||
|
|
||||||
|
def test_playoff_p4_higher_than_regular_season_p4(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
reg = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
||||||
|
playoff = make_game(
|
||||||
|
game_state="LIVE", period=4, seconds_remaining=600, game_type=3
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(playoff) > calculate_game_priority(reg)
|
||||||
|
|
||||||
|
def test_closeness_bonus_tied_beats_one_goal(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
tied = self._live_game(home_score=2, away_score=2)
|
||||||
|
one_goal = self._live_game(home_score=2, away_score=1)
|
||||||
|
assert calculate_game_priority(tied) > calculate_game_priority(one_goal)
|
||||||
|
|
||||||
|
def test_5_4_same_priority_as_1_0(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
high_scoring = self._live_game(home_score=5, away_score=4)
|
||||||
|
low_scoring = self._live_game(home_score=1, away_score=0)
|
||||||
|
assert calculate_game_priority(high_scoring) == calculate_game_priority(
|
||||||
|
low_scoring
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pp_in_ot_adds_200(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=4,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||||
|
|
||||||
|
def test_pp_late_p3_adds_150(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = self._live_game(period=3, seconds_remaining=240)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=240,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150
|
||||||
|
|
||||||
|
def test_pp_mid_p3_adds_100(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = self._live_game(period=3, seconds_remaining=600)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100
|
||||||
|
|
||||||
|
def test_pp_early_p3_adds_50(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = self._live_game(period=3, seconds_remaining=900)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=3,
|
||||||
|
seconds_remaining=900,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50
|
||||||
|
|
||||||
|
def test_pp_p1_adds_30(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
base = self._live_game(period=1, seconds_remaining=600)
|
||||||
|
with_pp = make_game(
|
||||||
|
game_state="LIVE",
|
||||||
|
period=1,
|
||||||
|
seconds_remaining=600,
|
||||||
|
situation={
|
||||||
|
"homeTeam": {"situationDescriptions": ["PP"]},
|
||||||
|
"awayTeam": {"situationDescriptions": ["SH"]},
|
||||||
|
"timeRemaining": "1:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30
|
||||||
|
|
||||||
|
def test_time_priority_increases_as_clock_runs(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||||
|
)
|
||||||
|
early = self._live_game(period=3, seconds_remaining=1100)
|
||||||
|
late = self._live_game(period=3, seconds_remaining=200)
|
||||||
|
assert calculate_game_priority(late) > calculate_game_priority(early)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetComebackBonus:
|
||||||
|
def setup_method(self):
|
||||||
|
app.games._score_cache.clear()
|
||||||
|
app.games._comeback_tracker.clear()
|
||||||
|
|
||||||
|
def test_returns_zero_on_first_call(self):
|
||||||
|
game = make_game(home_score=2, away_score=1)
|
||||||
|
assert get_comeback_bonus(game) == 0
|
||||||
|
|
||||||
|
def test_cache_populated_after_first_call(self):
|
||||||
|
game = make_game(home_score=2, away_score=1)
|
||||||
|
get_comeback_bonus(game)
|
||||||
|
assert ("Maple Leafs", "Bruins") in app.games._score_cache
|
||||||
|
|
||||||
|
def test_no_bonus_for_one_goal_swing(self):
|
||||||
|
# 1-goal swings are normal hockey, no bonus
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 1)
|
||||||
|
game = make_game(home_score=1, away_score=1, period=3)
|
||||||
|
assert get_comeback_bonus(game) == 0
|
||||||
|
|
||||||
|
def test_two_goal_recovery_in_p3(self):
|
||||||
|
# Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||||
|
game = make_game(home_score=2, away_score=2, period=3)
|
||||||
|
assert get_comeback_bonus(game) == 90 # 60*1.0 + 30
|
||||||
|
|
||||||
|
def test_three_goal_recovery_in_p3(self):
|
||||||
|
# Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 3)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||||
|
game = make_game(home_score=3, away_score=3, period=3)
|
||||||
|
assert get_comeback_bonus(game) == 150 # 120*1.0 + 30
|
||||||
|
|
||||||
|
def test_partial_recovery_in_p3(self):
|
||||||
|
# Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||||
|
game = make_game(home_score=2, away_score=3, period=3)
|
||||||
|
assert get_comeback_bonus(game) == 60 # 60*1.0
|
||||||
|
|
||||||
|
def test_bonus_persists_across_polls(self):
|
||||||
|
# Set up a 2-goal recovery, then call again — bonus stays
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||||
|
game = make_game(home_score=2, away_score=2, period=3)
|
||||||
|
first = get_comeback_bonus(game)
|
||||||
|
second = get_comeback_bonus(game)
|
||||||
|
assert first == second == 90
|
||||||
|
|
||||||
|
def test_period_multiplier_p1_lower(self):
|
||||||
|
# P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||||
|
game = make_game(home_score=2, away_score=2, period=1)
|
||||||
|
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
|
||||||
|
|
||||||
|
def test_ot_multiplier_higher(self):
|
||||||
|
# OT: base=60, period_mult=1.2, tie=30
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||||
|
game = make_game(home_score=2, away_score=2, period=4)
|
||||||
|
assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30)
|
||||||
|
|
||||||
|
def test_no_bonus_in_intermission(self):
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||||
|
game = make_game(home_score=2, away_score=2, in_intermission=True)
|
||||||
|
assert get_comeback_bonus(game) == 0
|
||||||
|
|
||||||
|
def test_no_bonus_for_non_live_state(self):
|
||||||
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 3)
|
||||||
|
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||||
|
game = make_game(game_state="OFF", home_score=3, away_score=3)
|
||||||
|
assert get_comeback_bonus(game) == 0
|
||||||
|
|
||||||
|
def test_tracker_builds_max_deficit_over_time(self):
|
||||||
|
# Simulate progressive scoring: 0-1, 0-2, 1-2, 2-2
|
||||||
|
key = ("Maple Leafs", "Bruins")
|
||||||
|
get_comeback_bonus(make_game(home_score=0, away_score=1, period=1))
|
||||||
|
get_comeback_bonus(make_game(home_score=0, away_score=2, period=1))
|
||||||
|
get_comeback_bonus(make_game(home_score=1, away_score=2, period=2))
|
||||||
|
result = get_comeback_bonus(make_game(home_score=2, away_score=2, period=3))
|
||||||
|
assert app.games._comeback_tracker[key] == 2
|
||||||
|
assert result == 90 # 60*1.0 + 30
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateGameImportance:
|
||||||
|
def _standings(
|
||||||
|
self,
|
||||||
|
league_seq=10,
|
||||||
|
l10_seq=10,
|
||||||
|
div="ATL",
|
||||||
|
conf="E",
|
||||||
|
gp=65,
|
||||||
|
wc=18,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"league_sequence": league_seq,
|
||||||
|
"league_l10_sequence": l10_seq,
|
||||||
|
"division_abbrev": div,
|
||||||
|
"conference_abbrev": conf,
|
||||||
|
"games_played": gp,
|
||||||
|
"wildcard_sequence": wc,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_playoff_game_gets_fallback_importance(self):
|
||||||
|
game = make_game(game_type=3)
|
||||||
|
assert calculate_game_importance(game) == 100
|
||||||
|
|
||||||
|
def test_playoff_game7_cup_final_is_max(self):
|
||||||
|
game = make_game(
|
||||||
|
game_type=3,
|
||||||
|
series_status={"round": 4, "topSeedWins": 3, "bottomSeedWins": 3},
|
||||||
|
)
|
||||||
|
assert calculate_game_importance(game) == 200
|
||||||
|
|
||||||
|
def test_playoff_elimination_round1(self):
|
||||||
|
game = make_game(
|
||||||
|
game_type=3,
|
||||||
|
series_status={"round": 1, "topSeedWins": 3, "bottomSeedWins": 2},
|
||||||
|
)
|
||||||
|
assert calculate_game_importance(game) == 170
|
||||||
|
|
||||||
|
def test_playoff_game1_round1_lowest(self):
|
||||||
|
game = make_game(
|
||||||
|
game_type=3,
|
||||||
|
series_status={"round": 1, "topSeedWins": 0, "bottomSeedWins": 0},
|
||||||
|
)
|
||||||
|
assert calculate_game_importance(game) == 80
|
||||||
|
|
||||||
|
def test_playoff_later_rounds_more_important(self):
|
||||||
|
series = {"topSeedWins": 2, "bottomSeedWins": 2}
|
||||||
|
r1 = make_game(game_type=3, series_status={**series, "round": 1})
|
||||||
|
r3 = make_game(game_type=3, series_status={**series, "round": 3})
|
||||||
|
assert calculate_game_importance(r3) > calculate_game_importance(r1)
|
||||||
|
|
||||||
|
def test_returns_zero_for_final_game(self):
|
||||||
|
game = make_game(game_state="OFF")
|
||||||
|
assert calculate_game_importance(game) == 0
|
||||||
|
|
||||||
|
def test_near_zero_early_in_season(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=10, wc=18),
|
||||||
|
)
|
||||||
|
game = make_game(game_state="FUT")
|
||||||
|
assert calculate_game_importance(game) <= 10
|
||||||
|
|
||||||
|
def test_max_bonus_late_season_bubble_division_game(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||||
|
)
|
||||||
|
game = make_game(game_state="FUT")
|
||||||
|
assert calculate_game_importance(game) == 150
|
||||||
|
|
||||||
|
def test_same_division_beats_same_conference(self, mocker):
|
||||||
|
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||||
|
away_st_same_div = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||||
|
away_st_diff_div = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
side_effect=[home_st, away_st_same_div],
|
||||||
|
)
|
||||||
|
result_div = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
side_effect=[home_st, away_st_diff_div],
|
||||||
|
)
|
||||||
|
result_conf = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
assert result_div > result_conf
|
||||||
|
|
||||||
|
def test_same_conference_beats_different_conference(self, mocker):
|
||||||
|
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||||
|
away_same_conf = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||||
|
away_diff_conf = self._standings(gp=70, wc=18, div="PAC", conf="W")
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
side_effect=[home_st, away_same_conf],
|
||||||
|
)
|
||||||
|
result_same = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
side_effect=[home_st, away_diff_conf],
|
||||||
|
)
|
||||||
|
result_diff = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
assert result_same > result_diff
|
||||||
|
|
||||||
|
def test_bubble_teams_beat_safely_in_teams(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=70, wc=18),
|
||||||
|
)
|
||||||
|
result_bubble = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=70, wc=5),
|
||||||
|
)
|
||||||
|
result_safe = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
|
||||||
|
assert result_bubble > result_safe
|
||||||
|
|
||||||
|
def test_eliminated_teams_have_lowest_relevance(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=70, wc=30),
|
||||||
|
)
|
||||||
|
assert calculate_game_importance(make_game(game_state="FUT")) < 30
|
||||||
|
|
||||||
|
def test_result_is_non_negative_int(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=0, wc=32),
|
||||||
|
)
|
||||||
|
result = calculate_game_importance(make_game(game_state="FUT"))
|
||||||
|
assert isinstance(result, int)
|
||||||
|
assert result >= 0
|
||||||
|
|
||||||
|
def test_result_never_exceeds_150(self, mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"app.games.get_team_standings",
|
||||||
|
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||||
|
)
|
||||||
|
assert calculate_game_importance(make_game(game_state="FUT")) <= 150
|
||||||
@@ -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