From 47a8c34215361e17cf21546cc7714cd4e4ebecdc Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 29 Mar 2026 18:39:55 -0400 Subject: [PATCH] feat: game importance factor in hype scoring Adds calculate_game_importance() that boosts Priority for high-stakes regular-season matchups based on season progress (sharp ramp after game 55), playoff bubble proximity (wildcard rank ~17-19 = max relevance), and divisional/conference rivalry (1.4x/1.2x multipliers). Max bonus 150 pts applied to both LIVE and PRE games; playoff and FINAL games are unaffected. Extends standings schema with division, conference, games_played, and wildcard_sequence fields fetched from the NHL API. Co-Authored-By: Claude Sonnet 4.6 --- app/games.py | 84 ++++++++++++++++++++++++++-- app/standings.py | 37 +++++++++++- tests/conftest.py | 3 +- tests/test_games.py | 121 ++++++++++++++++++++++++++++++++++++++++ tests/test_standings.py | 75 +++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 9 deletions(-) diff --git a/app/games.py b/app/games.py index 9ace49b..f70629f 100644 --- a/app/games.py +++ b/app/games.py @@ -52,7 +52,7 @@ def parse_games(scoreboard_data): "Intermission": game["clock"]["inIntermission"] if game_state == "LIVE" else "N/A", - "Priority": calculate_game_priority(game) + get_comeback_bonus(game), + "Priority": calculate_game_priority(game) + get_comeback_bonus(game) + calculate_game_importance(game), "Start Time": get_start_time(game), "Home Record": format_record(game["homeTeam"]["record"]) if game["gameState"] in ["PRE", "FUT"] @@ -290,20 +290,94 @@ def get_team_standings(team_name): cursor = conn.cursor() cursor.execute( """ - SELECT league_sequence, league_l10_sequence + 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": result[0] if result else 0, - "league_l10_sequence": result[1] if result else 0, + "league_sequence": 0, + "league_l10_sequence": 0, + "division_abbrev": None, + "conference_abbrev": None, + "games_played": 0, + "wildcard_sequence": 32, } +def calculate_game_importance(game): + # Playoff games already have elevated priorities; don't double-count + if game.get("gameType", 2) != 2: + return 0 + # FINAL/OFF games must sort below LIVE and PRE games + if game["gameState"] in ("FINAL", "OFF"): + return 0 + + 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 = int((raw / 1.4) * 150) + + logger.debug( + "importance components — season_weight: %.3f, playoff_relevance: %.2f, " + "rivalry: %.1f, importance: %s", + season_weight, + playoff_relevance, + rivalry_multiplier, + importance, + ) + + return max(0, min(importance, 150)) + + 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) diff --git a/app/standings.py b/app/standings.py index b5b59c9..5a59ac2 100644 --- a/app/standings.py +++ b/app/standings.py @@ -14,12 +14,31 @@ def create_standings_table(conn): CREATE TABLE IF NOT EXISTS standings ( team_common_name TEXT, league_sequence INTEGER, - league_l10_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") @@ -31,13 +50,20 @@ def insert_standings(conn, standings): for team in standings: cursor.execute( """ - INSERT INTO standings (team_common_name, league_sequence, league_l10_sequence) - VALUES (?, ?, ?) + 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() @@ -56,6 +82,10 @@ def fetch_standings(): "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 @@ -67,6 +97,7 @@ def fetch_standings(): 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) diff --git a/tests/conftest.py b/tests/conftest.py index 6b39114..b088677 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,7 +78,8 @@ def flask_client(tmp_path, monkeypatch): conn = sqlite3.connect(str(db_path)) conn.execute( "CREATE TABLE standings " - "(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER)" + "(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() diff --git a/tests/test_games.py b/tests/test_games.py index d36bbb7..a288976 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -1,6 +1,7 @@ import app.games from tests.conftest import make_game from app.games import ( + calculate_game_importance, calculate_game_priority, convert_game_state, format_record, @@ -413,3 +414,123 @@ class TestGetComebackBonus: app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3) game = make_game(home_score=2, away_score=3, period=4) assert get_comeback_bonus(game) == 100 + + +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_returns_zero_for_playoff_game(self): + game = make_game(game_type=3) + assert calculate_game_importance(game) == 0 + + 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 diff --git a/tests/test_standings.py b/tests/test_standings.py index 42ece95..8a174cc 100644 --- a/tests/test_standings.py +++ b/tests/test_standings.py @@ -6,6 +6,7 @@ from app.standings import ( create_standings_table, fetch_standings, insert_standings, + migrate_standings_table, refresh_standings, truncate_standings_table, ) @@ -16,11 +17,19 @@ SAMPLE_API_RESPONSE = { "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, }, ] } @@ -38,6 +47,10 @@ class TestFetchStandings: "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" @@ -98,6 +111,10 @@ class TestTruncateStandingsTable: "team_common_name": "Bruins", "league_sequence": 1, "league_l10_sequence": 2, + "division_abbrev": "ATL", + "conference_abbrev": "E", + "games_played": 60, + "wildcard_sequence": 5, } ], ) @@ -119,11 +136,19 @@ class TestInsertStandings: "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) @@ -142,6 +167,10 @@ class TestInsertStandings: "team_common_name": "Bruins", "league_sequence": 1, "league_l10_sequence": 2, + "division_abbrev": "ATL", + "conference_abbrev": "E", + "games_played": 60, + "wildcard_sequence": 5, } ], ) @@ -162,6 +191,10 @@ class TestRefreshStandings: "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) @@ -183,6 +216,10 @@ class TestRefreshStandings: "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) @@ -193,11 +230,19 @@ class TestRefreshStandings: "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) @@ -218,6 +263,10 @@ class TestRefreshStandings: "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) @@ -231,3 +280,29 @@ class TestRefreshStandings: 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()