Skip to content

Commit cc26e82

Browse files
committed
feat: Add live match score updates with real-time goal tracking
1 parent ef96ac0 commit cc26e82

16 files changed

+3328
-106
lines changed

.github/workflows/tests.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Tests and Linting
2+
3+
on:
4+
push:
5+
branches: [main, develop]
6+
pull_request:
7+
branches: [main, develop]
8+
9+
jobs:
10+
test-and-lint:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.9", "3.10", "3.11"]
15+
16+
steps:
17+
- uses: actions/checkout@v3
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v4
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -r requirements.txt
28+
29+
- name: Lint with pylint
30+
run: |
31+
pylint common.py premier_division.py first_division.py fai_cup.py live_updater.py rate_limiter.py
32+
33+
- name: Run all tests with coverage
34+
run: |
35+
python -m pytest tests/ -v --tb=short --cov=. --cov-report=xml --cov-report=term
36+
37+
- name: Upload coverage to Codecov
38+
uses: codecov/codecov-action@v3
39+
with:
40+
files: ./coverage.xml
41+
flags: unittests
42+
name: codecov-umbrella
43+
if: matrix.python-version == '3.11'

common.py

Lines changed: 252 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""
22
Just for common code shared bettwen our modules.
3-
Tempting to put the reddit posting stuff here
4-
-- maybe overengineering..
53
"""
64

5+
import json
6+
import os
77
from datetime import datetime
8-
import pytz
8+
from zoneinfo import ZoneInfo
9+
from typing import Dict, List, Tuple, Any
910

1011
normalised_team_names = {
1112
"St Patrick's Athl.": "St Patrick's Athletic",
@@ -15,7 +16,7 @@
1516
"Wexford": "Wexford FC",
1617
}
1718

18-
match_table_headers = ["Home Team", "Kickoff", "Away Team", "Ground"]
19+
match_table_headers = ["Home Team", "Score", "Away Team", "Ground", "Status"]
1920

2021

2122
table_headers = [
@@ -33,24 +34,51 @@
3334
]
3435

3536

36-
def normalise_team_name(team_name):
37-
"""Normalise team name."""
37+
CACHE_FILE = "match_cache.json"
38+
39+
40+
def normalise_team_name(team_name: str) -> str:
41+
"""Normalise team name.
42+
43+
Args:
44+
team_name: Team name from API
45+
46+
Returns:
47+
Normalized team name or original if no mapping exists
48+
"""
3849
return normalised_team_names.get(team_name, team_name)
3950

4051

41-
def ordinal_suffix(day):
42-
"""Add suffix to date header. Why is this not built into the Python standard library!?!"""
52+
def ordinal_suffix(day: int) -> str:
53+
"""Add suffix to date header.
54+
55+
Args:
56+
day: Day of month (1-31)
57+
58+
Returns:
59+
Day with ordinal suffix (e.g., "1st", "22nd", "3rd")
60+
"""
4361
suffixes = {1: "st", 2: "nd", 3: "rd"}
4462
if 4 <= day <= 20 or 24 <= day <= 30:
4563
return f"{day}th"
4664
return f"{day}{suffixes.get(day % 10, 'th')}"
4765

4866

49-
def get_last_matches(team_id, league_table):
50-
"""Return last five match results through emojis for a team."""
67+
def get_last_matches(team_id: int, league_table: List[Dict[str, Any]]) -> str:
68+
"""Return last five match results through emojis for a team.
69+
70+
Args:
71+
team_id: Team ID from API
72+
league_table: League standings data
73+
74+
Returns:
75+
String of emoji representing form (e.g., "✅⚪❌✅✅")
76+
"""
5177
last_matches = 5
5278
team_form = next(
53-
(team["form"] for team in league_table if team["team"]["id"] == team_id), None
79+
(team["form"] for team in league_table
80+
if team["team"]["id"] == team_id),
81+
None
5482
)
5583
if team_form:
5684
return (
@@ -62,13 +90,221 @@ def get_last_matches(team_id, league_table):
6290
return ""
6391

6492

65-
def parse_match_datetime(match_date_str, timezone_str="Europe/Dublin"):
66-
"""
67-
Parse the match datetime string and adjust for daylight saving time.
93+
def parse_match_datetime(
94+
match_date_str: str,
95+
timezone_str: str = "Europe/Dublin") -> datetime:
96+
"""Parse the match datetime string and adjust for daylight saving time.
97+
98+
Args:
99+
match_date_str: ISO format datetime string from API
100+
timezone_str: Target timezone (default: Europe/Dublin IST)
101+
102+
Returns:
103+
datetime object in the specified timezone
68104
"""
69-
timezone = pytz.timezone(timezone_str)
105+
timezone = ZoneInfo(timezone_str)
70106

71107
# Parse the match datetime in UTC and convert to the local timezone
72-
match_datetime_local = datetime.fromisoformat(match_date_str).astimezone(timezone)
108+
match_datetime_local = datetime.fromisoformat(
109+
match_date_str
110+
).astimezone(timezone)
73111

74112
return match_datetime_local
113+
114+
115+
def get_match_status_display(fixture: Dict[str, Any]) -> Tuple[str, str]:
116+
"""Get display text for match status and score.
117+
118+
Args:
119+
fixture: Fixture dictionary from API-Football
120+
121+
Returns:
122+
Tuple of (score_display, status_display)
123+
Examples: ("2-1", "45'"), ("vs", "19:45"), ("1-0", "FT")
124+
"""
125+
# Defensive null-checking for API response structure
126+
status = fixture.get("fixture", {}).get("status", {}).get("short", "NS")
127+
elapsed = fixture.get("fixture", {}).get("status", {}).get("elapsed")
128+
home_score = fixture.get("goals", {}).get("home", 0)
129+
away_score = fixture.get("goals", {}).get("away", 0)
130+
131+
# Ensure scores are not None (API can return null during pre-match)
132+
home_score = home_score if home_score is not None else 0
133+
away_score = away_score if away_score is not None else 0
134+
135+
score_display = f"{home_score}-{away_score}"
136+
137+
# Get kickoff time with null-safety
138+
fixture_date = fixture.get("fixture", {}).get("date")
139+
if fixture_date:
140+
kickoff_time = parse_match_datetime(fixture_date).strftime("%H:%M")
141+
else:
142+
kickoff_time = "TBD"
143+
144+
# Pre-match
145+
if status in ["TBD", "NS"]:
146+
return "vs", kickoff_time
147+
148+
# Live match statuses - map status codes to display text
149+
status_map = {
150+
"1H": f"{elapsed}'" if elapsed else "1H",
151+
"HT": "HT",
152+
"2H": f"{elapsed}'" if elapsed else "2H",
153+
"ET": "ET",
154+
"P": "Pens",
155+
"FT": "FT",
156+
"AET": "AET",
157+
"PEN": "Pens",
158+
}
159+
160+
if status in status_map:
161+
return score_display, status_map[status]
162+
163+
# Default fallback
164+
return "vs", status
165+
166+
167+
def format_live_fixture(fixture: Dict[str, Any]) -> List[str]:
168+
"""Format a fixture for display in match table with live score.
169+
170+
Args:
171+
fixture: Fixture dictionary from API-Football
172+
173+
Returns:
174+
List: [home_team, score, away_team, venue, status]
175+
"""
176+
home_team = normalise_team_name(
177+
fixture.get("teams", {}).get("home", {}).get("name", "Unknown")
178+
)
179+
away_team = normalise_team_name(
180+
fixture.get("teams", {}).get("away", {}).get("name", "Unknown")
181+
)
182+
venue = fixture.get("fixture", {}).get("venue", {}).get("name", "TBD")
183+
score, status = get_match_status_display(fixture)
184+
185+
return [home_team, score, away_team, venue, status]
186+
187+
188+
def load_cache() -> Dict[str, Any]:
189+
"""Load post metadata cache from JSON file.
190+
191+
Returns:
192+
Dictionary containing cached post metadata for each competition
193+
"""
194+
if os.path.exists(CACHE_FILE):
195+
with open(CACHE_FILE, "r", encoding="utf-8") as f:
196+
return json.load(f)
197+
return {}
198+
199+
200+
def save_cache(cache_data: Dict[str, Any]) -> None:
201+
"""Save post metadata cache to JSON file.
202+
203+
Args:
204+
cache_data: Dictionary containing post metadata to cache
205+
"""
206+
with open(CACHE_FILE, "w", encoding="utf-8") as f:
207+
json.dump(cache_data, f, indent=2)
208+
209+
210+
def extract_scorers(
211+
fixture: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
212+
"""Extract goal scorers from fixture events.
213+
214+
Args:
215+
fixture: Fixture dictionary containing events
216+
217+
Returns:
218+
Dictionary with 'home' and 'away' lists of scorer dictionaries.
219+
Each scorer dict contains: {name, minute, penalty, own_goal}
220+
"""
221+
scorers = {"home": [], "away": []}
222+
223+
if "events" not in fixture:
224+
return scorers
225+
226+
for event in fixture["events"]:
227+
if event.get("type") != "Goal":
228+
continue
229+
230+
scorer_info = {
231+
"name": event.get("player", {}).get("name", "Unknown"),
232+
"minute": event.get("time", {}).get("elapsed", 0),
233+
"penalty": event.get("detail", "") == "Penalty",
234+
"own_goal": event.get("detail", "") == "Own Goal",
235+
}
236+
237+
team = event.get("team", {}).get("name")
238+
home_team = fixture.get("teams", {}).get("home", {}).get("name")
239+
240+
if team and home_team and team == home_team:
241+
scorers["home"].append(scorer_info)
242+
elif team:
243+
scorers["away"].append(scorer_info)
244+
245+
return scorers
246+
247+
248+
def format_scorers_inline(fixture: Dict[str, Any]) -> str:
249+
"""Format goal scorers as a compact inline string (fully dynamic).
250+
251+
Returns scorers in format: "Team: Player (min'), Player (min') | Team: ..."
252+
Only includes teams with goals. Returns empty string if no goals.
253+
254+
Precedence: Home team first, then away team (only if both have scored).
255+
If only one team scored, shows that team only.
256+
257+
Args:
258+
fixture: Fixture dictionary
259+
260+
Returns:
261+
Formatted inline string, or empty string if no scorers
262+
"""
263+
scorers = extract_scorers(fixture)
264+
265+
home_team = normalise_team_name(
266+
fixture.get("teams", {}).get("home", {}).get("name", "Home")
267+
)
268+
away_team = normalise_team_name(
269+
fixture.get("teams", {}).get("away", {}).get("name", "Away")
270+
)
271+
272+
# Only show scorers if goals have been scored
273+
if not scorers["home"] and not scorers["away"]:
274+
return ""
275+
276+
parts = []
277+
278+
# Helper function to format scorer list
279+
def format_scorer_list(scorer_list: List[Dict[str, Any]]) -> List[str]:
280+
"""Format a list of scorers."""
281+
formatted = []
282+
for scorer in scorer_list:
283+
minute_str = f"{scorer['minute']}'"
284+
if scorer["own_goal"]:
285+
formatted.append(f"{scorer['name']} (OG {minute_str})")
286+
elif scorer["penalty"]:
287+
formatted.append(f"{scorer['name']} (P {minute_str})")
288+
else:
289+
formatted.append(f"{scorer['name']} ({minute_str})")
290+
return formatted
291+
292+
# Home team scorers (show if any goals) - FIRST precedence
293+
if scorers["home"]:
294+
scorer_list = format_scorer_list(scorers["home"])
295+
parts.append(f"**{home_team}:** {', '.join(scorer_list)}")
296+
297+
# Away team scorers (show if any goals) - SECOND precedence
298+
if scorers["away"]:
299+
scorer_list = format_scorer_list(scorers["away"])
300+
parts.append(f"**{away_team}:** {', '.join(scorer_list)}")
301+
302+
# Dynamically build result
303+
if len(parts) == 2:
304+
# Both teams scored: show with pipe separator
305+
return " | ".join(parts)
306+
if len(parts) == 1:
307+
# Only one team scored: show just that team
308+
return parts[0]
309+
310+
return ""

0 commit comments

Comments
 (0)