Skip to content

API Reference

Core Modules

API Client

NHL API client wrapper with caching and convenience methods.

NHLClient

Client for NHL API with caching and convenience methods.

Source code in src/faceoff/api/client.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class NHLClient:
    """Client for NHL API with caching and convenience methods."""

    BASE_URL = "https://api-web.nhle.com/v1"

    def __init__(self) -> None:
        self._http = httpx.AsyncClient(
            timeout=30.0,
            headers={"User-Agent": "Faceoff/1.0"},
            follow_redirects=True,
        )
        self._cache: dict[str, tuple[float, Any]] = {}
        self._cache_ttl = 30.0  # seconds

    async def _get(self, endpoint: str, cache_ttl: float | None = None) -> Any:
        """Make a GET request with optional caching."""
        url = f"{self.BASE_URL}{endpoint}"
        ttl = cache_ttl if cache_ttl is not None else self._cache_ttl

        # Check cache
        if url in self._cache:
            cached_time, cached_data = self._cache[url]
            if datetime.now().timestamp() - cached_time < ttl:
                return cached_data

        response = await self._http.get(url)
        response.raise_for_status()
        data = response.json()

        # Update cache
        self._cache[url] = (datetime.now().timestamp(), data)
        return data

    async def get_schedule(self, date: str | None = None) -> dict[str, Any]:
        """Get schedule for a specific date or today.

        Args:
            date: Date in YYYY-MM-DD format, or None for today

        Returns:
            Schedule data including games for the week
        """
        if date:
            return await self._get(f"/schedule/{date}")
        return await self._get("/schedule/now")

    async def get_scoreboard(self) -> dict[str, Any]:
        """Get current scoreboard with live game data."""
        return await self._get("/scoreboard/now", cache_ttl=10.0)

    async def get_game_boxscore(self, game_id: int) -> dict[str, Any]:
        """Get box score for a specific game."""
        return await self._get(f"/gamecenter/{game_id}/boxscore", cache_ttl=10.0)

    async def get_game_play_by_play(self, game_id: int) -> dict[str, Any]:
        """Get play-by-play data for a specific game."""
        return await self._get(f"/gamecenter/{game_id}/play-by-play", cache_ttl=10.0)

    async def get_game_landing(self, game_id: int) -> dict[str, Any]:
        """Get landing page data for a specific game (summary info)."""
        return await self._get(f"/gamecenter/{game_id}/landing", cache_ttl=10.0)

    async def get_game_right_rail(self, game_id: int) -> dict[str, Any]:
        """Get right-rail data for a specific game (team-level game stats)."""
        return await self._get(f"/gamecenter/{game_id}/right-rail", cache_ttl=10.0)

    async def get_standings(self, date: str | None = None) -> dict[str, Any]:
        """Get standings for a specific date or current."""
        if date:
            return await self._get(f"/standings/{date}")
        return await self._get("/standings/now")

    async def get_skater_stats_leaders(self) -> dict[str, Any]:
        """Get current skater stats leaders."""
        return await self._get("/skater-stats-leaders/current")

    async def get_goalie_stats_leaders(self) -> dict[str, Any]:
        """Get current goalie stats leaders."""
        return await self._get("/goalie-stats-leaders/current")

    async def get_team_roster(self, team_abbrev: str) -> dict[str, Any]:
        """Get current roster for a team."""
        return await self._get(f"/roster/{team_abbrev}/current")

    async def get_team_schedule(self, team_abbrev: str) -> dict[str, Any]:
        """Get current week schedule for a team."""
        return await self._get(f"/club-schedule/{team_abbrev}/week/now")

    async def get_team_month_schedule(self, team_abbrev: str, month: str | None = None) -> dict[str, Any]:
        """Get month schedule for a team (includes past games).

        Args:
            team_abbrev: Team abbreviation (e.g., 'TOR')
            month: Month in YYYY-MM format, or None for current month

        Returns:
            Schedule data for the month including past and future games
        """
        if month:
            return await self._get(f"/club-schedule/{team_abbrev}/month/{month}")
        current_month = datetime.now().strftime("%Y-%m")
        return await self._get(f"/club-schedule/{team_abbrev}/month/{current_month}")

    async def get_team_stats(self, team_abbrev: str) -> dict[str, Any]:
        """Get current stats for a team's players."""
        return await self._get(f"/club-stats/{team_abbrev}/now")

    async def get_player_landing(self, player_id: int) -> dict[str, Any]:
        """Get landing page data for a player."""
        return await self._get(f"/player/{player_id}/landing")

    async def get_player_game_log(self, player_id: int) -> dict[str, Any]:
        """Get game log for a player in current season."""
        return await self._get(f"/player/{player_id}/game-log/now")

    def clear_cache(self) -> None:
        """Clear the response cache."""
        self._cache.clear()

    async def aclose(self) -> None:
        """Close the HTTP client."""
        await self._http.aclose()

aclose() async

Close the HTTP client.

Source code in src/faceoff/api/client.py
128
129
130
async def aclose(self) -> None:
    """Close the HTTP client."""
    await self._http.aclose()

clear_cache()

Clear the response cache.

Source code in src/faceoff/api/client.py
124
125
126
def clear_cache(self) -> None:
    """Clear the response cache."""
    self._cache.clear()

get_game_boxscore(game_id) async

Get box score for a specific game.

Source code in src/faceoff/api/client.py
59
60
61
async def get_game_boxscore(self, game_id: int) -> dict[str, Any]:
    """Get box score for a specific game."""
    return await self._get(f"/gamecenter/{game_id}/boxscore", cache_ttl=10.0)

get_game_landing(game_id) async

Get landing page data for a specific game (summary info).

Source code in src/faceoff/api/client.py
67
68
69
async def get_game_landing(self, game_id: int) -> dict[str, Any]:
    """Get landing page data for a specific game (summary info)."""
    return await self._get(f"/gamecenter/{game_id}/landing", cache_ttl=10.0)

get_game_play_by_play(game_id) async

Get play-by-play data for a specific game.

Source code in src/faceoff/api/client.py
63
64
65
async def get_game_play_by_play(self, game_id: int) -> dict[str, Any]:
    """Get play-by-play data for a specific game."""
    return await self._get(f"/gamecenter/{game_id}/play-by-play", cache_ttl=10.0)

get_game_right_rail(game_id) async

Get right-rail data for a specific game (team-level game stats).

Source code in src/faceoff/api/client.py
71
72
73
async def get_game_right_rail(self, game_id: int) -> dict[str, Any]:
    """Get right-rail data for a specific game (team-level game stats)."""
    return await self._get(f"/gamecenter/{game_id}/right-rail", cache_ttl=10.0)

get_goalie_stats_leaders() async

Get current goalie stats leaders.

Source code in src/faceoff/api/client.py
85
86
87
async def get_goalie_stats_leaders(self) -> dict[str, Any]:
    """Get current goalie stats leaders."""
    return await self._get("/goalie-stats-leaders/current")

get_player_game_log(player_id) async

Get game log for a player in current season.

Source code in src/faceoff/api/client.py
120
121
122
async def get_player_game_log(self, player_id: int) -> dict[str, Any]:
    """Get game log for a player in current season."""
    return await self._get(f"/player/{player_id}/game-log/now")

get_player_landing(player_id) async

Get landing page data for a player.

Source code in src/faceoff/api/client.py
116
117
118
async def get_player_landing(self, player_id: int) -> dict[str, Any]:
    """Get landing page data for a player."""
    return await self._get(f"/player/{player_id}/landing")

get_schedule(date=None) async

Get schedule for a specific date or today.

Parameters:

Name Type Description Default
date str | None

Date in YYYY-MM-DD format, or None for today

None

Returns:

Type Description
dict[str, Any]

Schedule data including games for the week

Source code in src/faceoff/api/client.py
42
43
44
45
46
47
48
49
50
51
52
53
async def get_schedule(self, date: str | None = None) -> dict[str, Any]:
    """Get schedule for a specific date or today.

    Args:
        date: Date in YYYY-MM-DD format, or None for today

    Returns:
        Schedule data including games for the week
    """
    if date:
        return await self._get(f"/schedule/{date}")
    return await self._get("/schedule/now")

get_scoreboard() async

Get current scoreboard with live game data.

Source code in src/faceoff/api/client.py
55
56
57
async def get_scoreboard(self) -> dict[str, Any]:
    """Get current scoreboard with live game data."""
    return await self._get("/scoreboard/now", cache_ttl=10.0)

get_skater_stats_leaders() async

Get current skater stats leaders.

Source code in src/faceoff/api/client.py
81
82
83
async def get_skater_stats_leaders(self) -> dict[str, Any]:
    """Get current skater stats leaders."""
    return await self._get("/skater-stats-leaders/current")

get_standings(date=None) async

Get standings for a specific date or current.

Source code in src/faceoff/api/client.py
75
76
77
78
79
async def get_standings(self, date: str | None = None) -> dict[str, Any]:
    """Get standings for a specific date or current."""
    if date:
        return await self._get(f"/standings/{date}")
    return await self._get("/standings/now")

get_team_month_schedule(team_abbrev, month=None) async

Get month schedule for a team (includes past games).

Parameters:

Name Type Description Default
team_abbrev str

Team abbreviation (e.g., 'TOR')

required
month str | None

Month in YYYY-MM format, or None for current month

None

Returns:

Type Description
dict[str, Any]

Schedule data for the month including past and future games

Source code in src/faceoff/api/client.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
async def get_team_month_schedule(self, team_abbrev: str, month: str | None = None) -> dict[str, Any]:
    """Get month schedule for a team (includes past games).

    Args:
        team_abbrev: Team abbreviation (e.g., 'TOR')
        month: Month in YYYY-MM format, or None for current month

    Returns:
        Schedule data for the month including past and future games
    """
    if month:
        return await self._get(f"/club-schedule/{team_abbrev}/month/{month}")
    current_month = datetime.now().strftime("%Y-%m")
    return await self._get(f"/club-schedule/{team_abbrev}/month/{current_month}")

get_team_roster(team_abbrev) async

Get current roster for a team.

Source code in src/faceoff/api/client.py
89
90
91
async def get_team_roster(self, team_abbrev: str) -> dict[str, Any]:
    """Get current roster for a team."""
    return await self._get(f"/roster/{team_abbrev}/current")

get_team_schedule(team_abbrev) async

Get current week schedule for a team.

Source code in src/faceoff/api/client.py
93
94
95
async def get_team_schedule(self, team_abbrev: str) -> dict[str, Any]:
    """Get current week schedule for a team."""
    return await self._get(f"/club-schedule/{team_abbrev}/week/now")

get_team_stats(team_abbrev) async

Get current stats for a team's players.

Source code in src/faceoff/api/client.py
112
113
114
async def get_team_stats(self, team_abbrev: str) -> dict[str, Any]:
    """Get current stats for a team's players."""
    return await self._get(f"/club-stats/{team_abbrev}/now")

Screens

Schedule screen for browsing games.

ScheduleScreen

Bases: Screen

Screen for viewing the game schedule.

Source code in src/faceoff/screens/schedule.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class ScheduleScreen(Screen):
    """Screen for viewing the game schedule."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("h", "prev_day", "Prev Day"),
        Binding("l", "next_day", "Next Day"),
        Binding("t", "today", "Today"),
        Binding("s", "standings", "Standings"),
        Binding("p", "stats", "Stats"),
        Binding("m", "teams", "Teams"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
        Binding("left", "focus_prev_card", "Previous Game", show=False),
        Binding("right", "focus_next_card", "Next Game", show=False),
        Binding("up", "focus_card_above", "Game Above", show=False),
        Binding("down", "focus_card_below", "Game Below", show=False),
    ]

    DEFAULT_CSS = """
    ScheduleScreen {
        background: $surface;
    }

    ScheduleScreen .date-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    ScheduleScreen .date-nav {
        width: 100%;
        height: 1;
        align: center middle;
        color: $text-muted;
    }

    ScheduleScreen .games-scroll {
        width: 100%;
        height: 1fr;
        padding: 1 1;
    }

    ScheduleScreen .games-grid {
        width: 100%;
        height: auto;
    }

    ScheduleScreen .games-row {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }

    ScheduleScreen .no-games {
        width: 100%;
        height: auto;
        padding: 2;
        text-align: center;
        color: $text-muted;
    }

    ScheduleScreen .loading {
        width: 100%;
        height: auto;
        padding: 2;
        text-align: center;
    }
    """

    def __init__(self, client: NHLClient, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.current_date = get_nhl_today()
        self.games: list = []
        self._refresh_timer: Timer | None = None
        self._countdown_timer: Timer | None = None
        self._countdown: int = 30  # Reset from app.refresh_interval on mount
        self._last_width: int = 0

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(self._format_date(), classes="date-header", id="date-header")
        yield Static(
            "Arrows: Navigate | h/l: Change date | t: Today | s: Standings | p: Stats | m: Teams | r: Refresh | q: Quit",
            classes="date-nav",
        )
        with VerticalScroll(classes="games-scroll", id="games-scroll"), Vertical(classes="games-grid", id="games-grid"):
            yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load games when the screen is mounted."""
        self.load_games()
        interval = self.app.refresh_interval  # type: ignore[attr-defined]
        self._countdown = interval
        self._refresh_timer = self.set_interval(interval, callback=self._auto_refresh)  # type: ignore[arg-type]
        self._countdown_timer = self.set_interval(1, callback=self._update_countdown)
        self._update_subtitle()

    def on_unmount(self) -> None:
        """Clean up when screen is unmounted."""
        if self._refresh_timer:
            self._refresh_timer.stop()
        if self._countdown_timer:
            self._countdown_timer.stop()

    def _format_date(self) -> str:
        """Format the current date for display."""
        today = get_nhl_today()
        if self.current_date == today:
            day_label = "Today"
        elif self.current_date == today - timedelta(days=1):
            day_label = "Yesterday"
        elif self.current_date == today + timedelta(days=1):
            day_label = "Tomorrow"
        else:
            day_label = self.current_date.strftime("%A")

        return f"{day_label} - {self.current_date.strftime('%B %d, %Y')}"

    def load_games(self) -> None:
        """Load games for the current date."""
        self.run_worker(self._fetch_games())

    async def _fetch_games(self) -> None:
        """Fetch games from the API."""
        try:
            date_str = self.current_date.strftime("%Y-%m-%d")
            schedule = await self.client.get_schedule(date_str)

            # Extract games for the current date
            self.games = []
            game_week = schedule.get("gameWeek", [])
            for day in game_week:
                if day.get("date") == date_str:
                    self.games = day.get("games", [])
                    break

            self._update_games_display()
        except Exception as e:
            self.notify(f"Error loading games: {e}", severity="error")

    def _get_cards_per_row(self) -> int:
        """Calculate how many cards fit per row based on container width."""
        try:
            scroll = self.query_one("#games-scroll", VerticalScroll)
            # Account for padding (1 on each side) and some buffer
            available_width = scroll.size.width - 4
            cards_per_row = max(1, available_width // CARD_WIDTH)
        except Exception:
            return 2  # Default fallback
        else:
            return cards_per_row

    def _update_games_display(self) -> None:
        """Update the games container with loaded games."""
        grid = self.query_one("#games-grid", Vertical)
        grid.remove_children()

        # Update date header
        date_header = self.query_one("#date-header", Static)
        date_header.update(self._format_date())

        if not self.games:
            grid.mount(Label("No games scheduled", classes="no-games"))
            return

        cards_per_row = self._get_cards_per_row()

        # Create rows of game cards
        for i in range(0, len(self.games), cards_per_row):
            row_games = self.games[i : i + cards_per_row]
            row = Horizontal(classes="games-row")
            grid.mount(row)
            for game in row_games:
                card = GameCard(game)
                row.mount(card)

        # Focus the first game card
        cards = self.query(GameCard)
        if cards:
            cards[0].focus()

    def on_resize(self, event) -> None:
        """Handle terminal resize to reflow game cards."""
        # Only reflow if we have games and width changed significantly
        if not self.games:
            return
        try:
            scroll = self.query_one("#games-scroll", VerticalScroll)
            new_width = scroll.size.width
        except Exception:
            return
        if abs(new_width - self._last_width) >= CARD_WIDTH:
            self._last_width = new_width
            self._update_games_display()

    def _update_countdown(self) -> None:
        """Update the countdown timer every second."""
        self._countdown -= 1
        if self._countdown < 0:
            self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()

    def _update_subtitle(self) -> None:
        """Update the screen subtitle with countdown (only for today)."""
        if self.current_date == get_nhl_today():
            self.sub_title = f"Refreshing in {self._countdown}s"
        else:
            self.sub_title = ""

    def _auto_refresh(self) -> None:
        """Auto-refresh games (for live updates)."""
        self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        # Only refresh if viewing today's games
        if self.current_date == get_nhl_today():
            self._update_subtitle()
            self.client.clear_cache()
            self.load_games()

    def on_game_card_selected(self, event: GameCard.Selected) -> None:
        """Handle game card selection."""
        from faceoff.screens.game import GameScreen
        from faceoff.screens.pregame import PreGameScreen

        game_state = event.game_data.get("gameState", "FUT")
        game_schedule_state = event.game_data.get("gameScheduleState", "OK")

        # Check for cancelled/postponed games
        if game_schedule_state == "PPD":
            self.notify("This game has been postponed", severity="warning")
            return
        if game_schedule_state == "CNCL":
            self.notify("This game has been cancelled", severity="warning")
            return

        # Pre-game or future games show matchup preview
        if game_state in ("FUT", "PRE"):
            self.app.push_screen(PreGameScreen(self.client, event.game_id, event.game_data))
            return

        self.app.push_screen(GameScreen(self.client, event.game_id, event.game_data))

    def action_prev_day(self) -> None:
        """Go to previous day."""
        self.current_date -= timedelta(days=1)
        self._update_subtitle()
        self.load_games()

    def action_next_day(self) -> None:
        """Go to next day."""
        self.current_date += timedelta(days=1)
        self._update_subtitle()
        self.load_games()

    def action_today(self) -> None:
        """Go to today."""
        self.current_date = get_nhl_today()
        self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()
        self.load_games()

    def action_refresh(self) -> None:
        """Manually refresh games."""
        self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()
        self.client.clear_cache()
        self.load_games()
        self.notify("Refreshed")

    def action_standings(self) -> None:
        """Show standings screen."""
        from faceoff.screens.standings import StandingsScreen

        self.app.push_screen(StandingsScreen(self.client))

    def action_stats(self) -> None:
        """Show stats screen."""
        from faceoff.screens.stats import StatsScreen

        self.app.push_screen(StatsScreen(self.client))

    def action_teams(self) -> None:
        """Show teams screen."""
        from faceoff.screens.teams import TeamsScreen

        self.app.push_screen(TeamsScreen(self.client))

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

    def _get_focused_card_index(self) -> int:
        """Get the index of the currently focused card, or -1 if none."""
        cards = list(self.query(GameCard))
        for i, card in enumerate(cards):
            if card.has_focus:
                return i
        return -1

    def _focus_card_at_index(self, index: int) -> None:
        """Focus the card at the given index."""
        cards = list(self.query(GameCard))
        if cards and 0 <= index < len(cards):
            cards[index].focus()

    def action_focus_prev_card(self) -> None:
        """Focus the previous game card."""
        idx = self._get_focused_card_index()
        if idx > 0:
            self._focus_card_at_index(idx - 1)
        elif idx == -1:
            self._focus_card_at_index(0)

    def action_focus_next_card(self) -> None:
        """Focus the next game card."""
        idx = self._get_focused_card_index()
        cards = list(self.query(GameCard))
        if idx < len(cards) - 1:
            self._focus_card_at_index(idx + 1)

    def action_focus_card_above(self) -> None:
        """Focus the game card above (previous row, same column)."""
        idx = self._get_focused_card_index()
        if idx < 0:
            return
        cards_per_row = self._get_cards_per_row()
        new_idx = idx - cards_per_row
        if new_idx >= 0:
            self._focus_card_at_index(new_idx)

    def action_focus_card_below(self) -> None:
        """Focus the game card below (next row, same column)."""
        idx = self._get_focused_card_index()
        if idx < 0:
            return
        cards = list(self.query(GameCard))
        cards_per_row = self._get_cards_per_row()
        new_idx = idx + cards_per_row
        if new_idx < len(cards):
            self._focus_card_at_index(new_idx)

action_focus_card_above()

Focus the game card above (previous row, same column).

Source code in src/faceoff/screens/schedule.py
352
353
354
355
356
357
358
359
360
def action_focus_card_above(self) -> None:
    """Focus the game card above (previous row, same column)."""
    idx = self._get_focused_card_index()
    if idx < 0:
        return
    cards_per_row = self._get_cards_per_row()
    new_idx = idx - cards_per_row
    if new_idx >= 0:
        self._focus_card_at_index(new_idx)

action_focus_card_below()

Focus the game card below (next row, same column).

Source code in src/faceoff/screens/schedule.py
362
363
364
365
366
367
368
369
370
371
def action_focus_card_below(self) -> None:
    """Focus the game card below (next row, same column)."""
    idx = self._get_focused_card_index()
    if idx < 0:
        return
    cards = list(self.query(GameCard))
    cards_per_row = self._get_cards_per_row()
    new_idx = idx + cards_per_row
    if new_idx < len(cards):
        self._focus_card_at_index(new_idx)

action_focus_next_card()

Focus the next game card.

Source code in src/faceoff/screens/schedule.py
345
346
347
348
349
350
def action_focus_next_card(self) -> None:
    """Focus the next game card."""
    idx = self._get_focused_card_index()
    cards = list(self.query(GameCard))
    if idx < len(cards) - 1:
        self._focus_card_at_index(idx + 1)

action_focus_prev_card()

Focus the previous game card.

Source code in src/faceoff/screens/schedule.py
337
338
339
340
341
342
343
def action_focus_prev_card(self) -> None:
    """Focus the previous game card."""
    idx = self._get_focused_card_index()
    if idx > 0:
        self._focus_card_at_index(idx - 1)
    elif idx == -1:
        self._focus_card_at_index(0)

action_next_day()

Go to next day.

Source code in src/faceoff/screens/schedule.py
280
281
282
283
284
def action_next_day(self) -> None:
    """Go to next day."""
    self.current_date += timedelta(days=1)
    self._update_subtitle()
    self.load_games()

action_prev_day()

Go to previous day.

Source code in src/faceoff/screens/schedule.py
274
275
276
277
278
def action_prev_day(self) -> None:
    """Go to previous day."""
    self.current_date -= timedelta(days=1)
    self._update_subtitle()
    self.load_games()

action_quit()

Quit the application.

Source code in src/faceoff/screens/schedule.py
319
320
321
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh games.

Source code in src/faceoff/screens/schedule.py
293
294
295
296
297
298
299
def action_refresh(self) -> None:
    """Manually refresh games."""
    self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
    self._update_subtitle()
    self.client.clear_cache()
    self.load_games()
    self.notify("Refreshed")

action_standings()

Show standings screen.

Source code in src/faceoff/screens/schedule.py
301
302
303
304
305
def action_standings(self) -> None:
    """Show standings screen."""
    from faceoff.screens.standings import StandingsScreen

    self.app.push_screen(StandingsScreen(self.client))

action_stats()

Show stats screen.

Source code in src/faceoff/screens/schedule.py
307
308
309
310
311
def action_stats(self) -> None:
    """Show stats screen."""
    from faceoff.screens.stats import StatsScreen

    self.app.push_screen(StatsScreen(self.client))

action_teams()

Show teams screen.

Source code in src/faceoff/screens/schedule.py
313
314
315
316
317
def action_teams(self) -> None:
    """Show teams screen."""
    from faceoff.screens.teams import TeamsScreen

    self.app.push_screen(TeamsScreen(self.client))

action_today()

Go to today.

Source code in src/faceoff/screens/schedule.py
286
287
288
289
290
291
def action_today(self) -> None:
    """Go to today."""
    self.current_date = get_nhl_today()
    self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
    self._update_subtitle()
    self.load_games()

load_games()

Load games for the current date.

Source code in src/faceoff/screens/schedule.py
151
152
153
def load_games(self) -> None:
    """Load games for the current date."""
    self.run_worker(self._fetch_games())

on_game_card_selected(event)

Handle game card selection.

Source code in src/faceoff/screens/schedule.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def on_game_card_selected(self, event: GameCard.Selected) -> None:
    """Handle game card selection."""
    from faceoff.screens.game import GameScreen
    from faceoff.screens.pregame import PreGameScreen

    game_state = event.game_data.get("gameState", "FUT")
    game_schedule_state = event.game_data.get("gameScheduleState", "OK")

    # Check for cancelled/postponed games
    if game_schedule_state == "PPD":
        self.notify("This game has been postponed", severity="warning")
        return
    if game_schedule_state == "CNCL":
        self.notify("This game has been cancelled", severity="warning")
        return

    # Pre-game or future games show matchup preview
    if game_state in ("FUT", "PRE"):
        self.app.push_screen(PreGameScreen(self.client, event.game_id, event.game_data))
        return

    self.app.push_screen(GameScreen(self.client, event.game_id, event.game_data))

on_mount()

Load games when the screen is mounted.

Source code in src/faceoff/screens/schedule.py
121
122
123
124
125
126
127
128
def on_mount(self) -> None:
    """Load games when the screen is mounted."""
    self.load_games()
    interval = self.app.refresh_interval  # type: ignore[attr-defined]
    self._countdown = interval
    self._refresh_timer = self.set_interval(interval, callback=self._auto_refresh)  # type: ignore[arg-type]
    self._countdown_timer = self.set_interval(1, callback=self._update_countdown)
    self._update_subtitle()

on_resize(event)

Handle terminal resize to reflow game cards.

Source code in src/faceoff/screens/schedule.py
214
215
216
217
218
219
220
221
222
223
224
225
226
def on_resize(self, event) -> None:
    """Handle terminal resize to reflow game cards."""
    # Only reflow if we have games and width changed significantly
    if not self.games:
        return
    try:
        scroll = self.query_one("#games-scroll", VerticalScroll)
        new_width = scroll.size.width
    except Exception:
        return
    if abs(new_width - self._last_width) >= CARD_WIDTH:
        self._last_width = new_width
        self._update_games_display()

on_unmount()

Clean up when screen is unmounted.

Source code in src/faceoff/screens/schedule.py
130
131
132
133
134
135
def on_unmount(self) -> None:
    """Clean up when screen is unmounted."""
    if self._refresh_timer:
        self._refresh_timer.stop()
    if self._countdown_timer:
        self._countdown_timer.stop()

get_nhl_today()

Get the current date in NHL timezone (Eastern Time).

Source code in src/faceoff/screens/schedule.py
24
25
26
def get_nhl_today() -> date:
    """Get the current date in NHL timezone (Eastern Time)."""
    return datetime.now(NHL_TIMEZONE).date()

Game screen for viewing a single game's details.

GameScreen

Bases: Screen

Screen for viewing a single game's details.

Source code in src/faceoff/screens/game.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
class GameScreen(Screen):
    """Screen for viewing a single game's details."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
    ]

    DEFAULT_CSS = """
    GameScreen {
        background: $surface;
    }

    GameScreen .game-content {
        width: 100%;
        height: 1fr;
    }

    GameScreen .main-content {
        width: 100%;
        height: auto;
        padding: 0 1;
    }

    GameScreen .left-panel {
        width: 1fr;
        height: auto;
    }

    GameScreen .right-panel {
        width: 1fr;
        height: auto;
        margin-left: 1;
    }

    GameScreen .score-box {
        width: 100%;
        height: auto;
        border: solid $primary;
        margin-bottom: 1;
        padding: 0 1;
    }

    GameScreen .score-line {
        width: 100%;
        height: 1;
    }

    GameScreen .score-away {
        width: 5;
        text-style: bold;
    }

    GameScreen .score-vs {
        width: 3;
        text-align: center;
    }

    GameScreen .score-home {
        width: 5;
        text-style: bold;
    }

    GameScreen .score-away-val {
        width: 3;
        text-align: right;
    }

    GameScreen .score-separator {
        width: 3;
        text-align: center;
    }

    GameScreen .score-home-val {
        width: 3;
        text-align: left;
    }

    GameScreen .score-home-val.-winning {
        color: $success;
    }

    GameScreen .score-away-val.-winning {
        color: $success;
    }

    GameScreen .score-status {
        width: 1fr;
        text-align: right;
        color: $success;
    }

    GameScreen .section-title {
        text-style: bold;
        background: $primary;
        padding: 0 1;
        width: 100%;
    }

    GameScreen .goals-section {
        width: 100%;
        height: auto;
        border: solid $primary;
        margin-bottom: 1;
    }

    GameScreen .goal-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    GameScreen .goal-time {
        width: 10;
        color: $text-muted;
    }

    GameScreen .goal-team {
        width: 5;
        text-style: bold;
        margin-left: 1;
    }

    GameScreen .goal-scorer {
        width: 1fr;
        color: $success;
    }

    GameScreen .goal-assists {
        width: 1fr;
        color: $text-muted;
    }

    GameScreen .stats-section {
        width: 100%;
        height: auto;
        border: solid $primary;
        margin-bottom: 1;
    }

    GameScreen .stats-header {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    GameScreen .stats-away {
        width: 6;
        text-align: right;
        text-style: bold;
    }

    GameScreen .stats-label {
        width: 1fr;
        text-align: center;
    }

    GameScreen .stats-home {
        width: 6;
        text-align: left;
        text-style: bold;
    }

    GameScreen .stats-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    GameScreen .pbp-section {
        width: 100%;
        height: auto;
        border: solid $primary;
    }

    GameScreen .pbp-section.-scrollable {
        height: 20;
        max-height: 20;
    }

    GameScreen .pbp-scroll {
        width: 100%;
        height: 100%;
    }

    GameScreen .pbp-item {
        width: 100%;
        height: auto;
        padding: 0 1;
    }

    GameScreen .pbp-team {
        width: 5;
        text-style: bold;
        margin-right: 1;
    }

    GameScreen .pbp-time {
        width: 8;
        color: $text-muted;
    }

    GameScreen .pbp-event {
        width: 1fr;
    }

    GameScreen .pbp-goal {
        color: $success;
        text-style: bold;
    }

    GameScreen .pbp-penalty {
        color: $warning;
    }

    GameScreen .pbp-period {
        width: 100%;
        background: $surface-lighten-1;
        text-style: bold;
        text-align: center;
        padding: 0 1;
        margin: 1 0;
    }

    GameScreen .no-data {
        color: $text-muted;
        padding: 1;
    }
    """

    def __init__(self, client: NHLClient, game_id: int, game_data: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.game_id = game_id
        self.game_data = game_data
        self.boxscore: dict = {}
        self.play_by_play: list = []
        self.scoring_summary: list = []  # From landing page
        self.team_game_stats: list = []  # From right-rail endpoint
        self._refresh_timer: Timer | None = None
        self._countdown_timer: Timer | None = None
        self._countdown: int = 30  # Reset from app.refresh_interval on mount
        self._last_width: int = 0

    def compose(self) -> ComposeResult:
        yield Header()
        with VerticalScroll(classes="game-content", id="game-content"):
            yield Horizontal(classes="main-content", id="main-content")
        yield Footer()

    def on_mount(self) -> None:
        """Load game data when screen is mounted."""
        self.load_game_data()

        # Set up auto-refresh for pre-game and live games
        game_state = self.game_data.get("gameState", "FUT")
        if game_state in ("PRE", "LIVE", "CRIT"):
            interval = self.app.refresh_interval  # type: ignore[attr-defined]
            self._countdown = interval
            self._refresh_timer = self.set_interval(interval, callback=self._auto_refresh)  # type: ignore[arg-type]
            self._countdown_timer = self.set_interval(1, callback=self._update_countdown)
            self._update_subtitle()

    def on_unmount(self) -> None:
        """Clean up when screen is unmounted."""
        if self._refresh_timer:
            self._refresh_timer.stop()
        if self._countdown_timer:
            self._countdown_timer.stop()

    def on_resize(self, event) -> None:
        """Handle resize to adjust layout."""
        try:
            content = self.query_one("#game-content", VerticalScroll)
            new_width = content.size.width
        except Exception:
            return

        # Only re-layout if width changed significantly
        if abs(new_width - self._last_width) >= 20:
            self._last_width = new_width
            self._update_main_content()

    def load_game_data(self) -> None:
        """Load detailed game data."""
        self.run_worker(self._fetch_game_data())

    async def _fetch_game_data(self) -> None:
        """Fetch game data from the API."""
        try:
            # Fetch boxscore, play-by-play, landing page, and right-rail team stats
            self.boxscore = await self.client.get_game_boxscore(self.game_id)
            pbp_data = await self.client.get_game_play_by_play(self.game_id)
            landing = await self.client.get_game_landing(self.game_id)
            right_rail = await self.client.get_game_right_rail(self.game_id)
            self.team_game_stats = right_rail.get("teamGameStats", [])

            # Update game data from boxscore
            if self.boxscore:
                self._update_game_from_boxscore()

            # Extract plays
            self.play_by_play = pbp_data.get("plays", [])

            # Extract scoring summary from landing page (has player names)
            summary = landing.get("summary", {})
            self.scoring_summary = []
            for period in summary.get("scoring", []):
                period_desc = period.get("periodDescriptor", {})
                for goal in period.get("goals", []):
                    # Add period info to each goal
                    goal["periodDescriptor"] = period_desc
                    self.scoring_summary.append(goal)

            self._update_display()
        except Exception as e:
            self.notify(f"Error loading game data: {e}", severity="error")

    def _update_game_from_boxscore(self) -> None:
        """Update game data from boxscore response."""
        if "awayTeam" in self.boxscore:
            self.game_data["awayTeam"] = self.boxscore["awayTeam"]
        if "homeTeam" in self.boxscore:
            self.game_data["homeTeam"] = self.boxscore["homeTeam"]
        if "gameState" in self.boxscore:
            self.game_data["gameState"] = self.boxscore["gameState"]
        if "clock" in self.boxscore:
            self.game_data["clock"] = self.boxscore["clock"]
        if "periodDescriptor" in self.boxscore:
            self.game_data["periodDescriptor"] = self.boxscore["periodDescriptor"]

    def _update_display(self) -> None:
        """Update all display components."""
        self._update_main_content()

    def _is_wide_layout(self) -> bool:
        """Check if we should use wide (side-by-side) layout."""
        try:
            content = self.query_one("#game-content", VerticalScroll)
        except Exception:
            return False
        else:
            return content.size.width >= WIDE_LAYOUT_MIN_WIDTH

    def _update_main_content(self) -> None:
        """Update the main content area with goals, stats, and play-by-play."""
        container = self.query_one("#main-content", Horizontal)
        container.remove_children()

        if not self.boxscore:
            container.mount(Label("Loading game data...", classes="no-data"))
            return

        wide = self._is_wide_layout()

        if wide:
            # Side-by-side layout: left panel (score + goals + stats), right panel (play-by-play)
            left_panel = Vertical(classes="left-panel")
            left_panel.compose_add_child(self._build_score_box())
            left_panel.compose_add_child(self._build_goals_section())
            left_panel.compose_add_child(self._build_stats_section())

            right_panel = Vertical(classes="right-panel")
            right_panel.compose_add_child(self._build_pbp_section(scrollable=True))

            container.mount(left_panel)
            container.mount(right_panel)
        else:
            # Stacked layout: score, goals, stats, then play-by-play
            single_panel = Vertical(classes="left-panel")
            single_panel.compose_add_child(self._build_score_box())
            single_panel.compose_add_child(self._build_goals_section())
            single_panel.compose_add_child(self._build_stats_section())
            single_panel.compose_add_child(self._build_pbp_section(scrollable=False))
            container.mount(single_panel)

    def _build_score_box(self) -> Vertical:
        """Build a compact score display box."""
        section = Vertical(classes="score-box")

        away_team = self.boxscore.get("awayTeam", {})
        home_team = self.boxscore.get("homeTeam", {})
        away_abbrev = away_team.get("abbrev", "AWY")
        home_abbrev = home_team.get("abbrev", "HOM")
        away_score = away_team.get("score", 0)
        home_score = home_team.get("score", 0)
        game_state = self.game_data.get("gameState", "FUT")

        # Score line: BOS @ DAL  1 - 6  3rd 06:01
        score_line = Horizontal(classes="score-line")
        score_line.compose_add_child(Label(away_abbrev, classes="score-away"))
        score_line.compose_add_child(Label("@", classes="score-vs"))
        score_line.compose_add_child(Label(home_abbrev, classes="score-home"))

        away_class = "score-away-val -winning" if away_score > home_score else "score-away-val"
        home_class = "score-home-val -winning" if home_score > away_score else "score-home-val"

        if game_state not in ("FUT", "PRE"):
            score_line.compose_add_child(Label(str(away_score), classes=away_class))
            score_line.compose_add_child(Label("-", classes="score-separator"))
            score_line.compose_add_child(Label(str(home_score), classes=home_class))
        else:
            score_line.compose_add_child(Label("-", classes="score-away-val"))
            score_line.compose_add_child(Label("-", classes="score-separator"))
            score_line.compose_add_child(Label("-", classes="score-home-val"))

        # Status text
        status = self._get_status_text()
        score_line.compose_add_child(Label(status, classes="score-status"))

        section.compose_add_child(score_line)
        return section

    def _get_status_text(self) -> str:
        """Get game status text."""
        game_state = self.game_data.get("gameState", "FUT")

        if game_state in ("LIVE", "CRIT"):
            period = self.game_data.get("periodDescriptor", {})
            period_num = period.get("number", 0)
            period_type = period.get("periodType", "REG")

            if period_type == "OT":
                period_str = "OT"
            elif period_type == "SO":
                period_str = "SO"
            else:
                ordinals = {1: "1st", 2: "2nd", 3: "3rd"}
                period_str = ordinals.get(period_num, f"{period_num}th")

            clock = self.game_data.get("clock", {})
            time_remaining = clock.get("timeRemaining", "20:00")
            in_intermission = clock.get("inIntermission", False)

            if in_intermission:
                return f"{period_str} INT"
            return f"{period_str} {time_remaining}"

        if game_state in ("FINAL", "OFF"):
            period = self.game_data.get("periodDescriptor", {})
            period_type = period.get("periodType", "REG")
            if period_type == "OT":
                return "Final/OT"
            if period_type == "SO":
                return "Final/SO"
            return "Final"

        if game_state == "PRE":
            return "Pre-game"

        return game_state

    def _build_goals_section(self) -> Vertical:
        """Build the goals/scoring summary section."""
        section = Vertical(classes="goals-section")
        section.compose_add_child(Static("Scoring Summary", classes="section-title"))

        if not self.scoring_summary:
            section.compose_add_child(Label("No goals scored yet", classes="no-data"))
            return section

        for goal in self.scoring_summary:
            row = self._build_goal_row(goal)
            section.compose_add_child(row)

        return section

    def _build_goal_row(self, goal: dict) -> Horizontal:
        """Build a single goal row widget."""
        # Get period info
        period_desc = goal.get("periodDescriptor", {}) if "periodDescriptor" in goal else {}
        period_num = period_desc.get("number", 0)
        period_type = period_desc.get("periodType", "REG")

        if period_type == "OT":
            period_label = "OT"
        elif period_type == "SO":
            period_label = "SO"
        else:
            period_label = f"P{period_num}"

        time_in_period = goal.get("timeInPeriod", "")
        team = goal.get("teamAbbrev", {}).get("default", "???")

        # Get scorer name from landing page data
        scorer_name = goal.get("name", {}).get("default", "")
        if not scorer_name:
            first = goal.get("firstName", {}).get("default", "")
            last = goal.get("lastName", {}).get("default", "")
            scorer_name = f"{first} {last}".strip()

        # Get assists from landing page data
        assists = []
        for assist in goal.get("assists", []):
            assist_name = assist.get("name", {}).get("default", "")
            if not assist_name:
                first = assist.get("firstName", {}).get("default", "")
                last = assist.get("lastName", {}).get("default", "")
                assist_name = f"{first} {last}".strip()
            if assist_name:
                assists.append(assist_name)

        row = Horizontal(classes="goal-row")
        row.compose_add_child(Label(f"{period_label} {time_in_period}", classes="goal-time"))
        row.compose_add_child(Label(team, classes="goal-team"))
        row.compose_add_child(Label(scorer_name or "Goal", classes="goal-scorer"))
        if assists:
            row.compose_add_child(Label(f"({', '.join(assists)})", classes="goal-assists"))
        return row

    def _build_stats_section(self) -> Vertical:
        """Build the game stats comparison section."""
        section = Vertical(classes="stats-section")

        away_team = self.boxscore.get("awayTeam", {})
        home_team = self.boxscore.get("homeTeam", {})
        away_abbrev = away_team.get("abbrev", "AWY")
        home_abbrev = home_team.get("abbrev", "HOM")

        # Header with team names
        header = Horizontal(classes="stats-header")
        header.compose_add_child(Label(away_abbrev, classes="stats-away"))
        header.compose_add_child(Label("Game Stats", classes="stats-label"))
        header.compose_add_child(Label(home_abbrev, classes="stats-home"))
        section.compose_add_child(header)

        stats_by_category = {item.get("category"): item for item in self.team_game_stats}

        def fmt(category: str, as_pct: bool = False) -> tuple[str, str]:
            entry = stats_by_category.get(category)
            if entry is None:
                return ("—", "—")
            away_val = entry.get("awayValue", 0)
            home_val = entry.get("homeValue", 0)
            if as_pct:
                return (f"{float(away_val):.0%}", f"{float(home_val):.0%}")
            return (str(away_val), str(home_val))

        away_sog, home_sog = fmt("sog")
        away_fo, home_fo = fmt("faceoffWinningPctg", as_pct=True)
        away_pp, home_pp = fmt("powerPlay")
        away_pim, home_pim = fmt("pim")
        away_hits, home_hits = fmt("hits")
        away_blocks, home_blocks = fmt("blockedShots")
        away_give, home_give = fmt("giveaways")
        away_take, home_take = fmt("takeaways")

        stats_rows = [
            (away_sog, "Shots", home_sog),
            (away_fo, "Faceoff %", home_fo),
            (away_pp, "Power Play", home_pp),
            (away_pim, "PIM", home_pim),
            (away_hits, "Hits", home_hits),
            (away_blocks, "Blocked Shots", home_blocks),
            (away_give, "Giveaways", home_give),
            (away_take, "Takeaways", home_take),
        ]

        for away_val, label, home_val in stats_rows:
            row = Horizontal(classes="stats-row")
            row.compose_add_child(Label(away_val, classes="stats-away"))
            row.compose_add_child(Label(label, classes="stats-label"))
            row.compose_add_child(Label(home_val, classes="stats-home"))
            section.compose_add_child(row)

        return section

    def _build_pbp_section(self, scrollable: bool = False) -> Vertical:
        """Build the play-by-play section."""
        classes = "pbp-section -scrollable" if scrollable else "pbp-section"
        section = Vertical(classes=classes)
        section.compose_add_child(Static("Play-by-Play", classes="section-title"))

        if not self.play_by_play:
            section.compose_add_child(Label("No plays yet", classes="no-data"))
            return section

        # Get team info for play descriptions
        away_id = self.boxscore.get("awayTeam", {}).get("id")
        home_id = self.boxscore.get("homeTeam", {}).get("id")
        away_abbrev = self.boxscore.get("awayTeam", {}).get("abbrev", "AWY")
        home_abbrev = self.boxscore.get("homeTeam", {}).get("abbrev", "HOM")
        team_map = {away_id: away_abbrev, home_id: home_abbrev}

        # Use VerticalScroll if scrollable
        if scrollable:
            scroll = VerticalScroll(classes="pbp-scroll")
            container = scroll
        else:
            container = section

        current_period = None

        # Show plays in reverse order (most recent first)
        for play in reversed(self.play_by_play):
            period_desc = play.get("periodDescriptor", {})
            period_num = period_desc.get("number", 0)
            period_type = period_desc.get("periodType", "REG")

            if period_type == "OT":
                period_label = "Overtime"
            elif period_type == "SO":
                period_label = "Shootout"
            else:
                ordinals = {1: "1st Period", 2: "2nd Period", 3: "3rd Period"}
                period_label = ordinals.get(period_num, f"{period_num}th Period")

            # Add period header if changed
            if period_label != current_period:
                current_period = period_label
                container.compose_add_child(Static(period_label, classes="pbp-period"))

            # Render play with team info
            play_widget = self._render_play(play, team_map)
            if play_widget:
                container.compose_add_child(play_widget)

        if scrollable:
            section.compose_add_child(scroll)

        return section

    def _render_play(self, play: dict, team_map: dict | None = None) -> Horizontal | None:
        """Render a single play event."""
        event_type = play.get("typeDescKey", "")
        time_in_period = play.get("timeInPeriod", "")
        details = play.get("details", {})

        # Skip certain event types to reduce noise
        if event_type in ("game-end", "period-start", "period-end"):
            return None

        # Get team abbreviation for this event
        team_abbrev = ""
        if team_map:
            event_team_id = details.get("eventOwnerTeamId")
            team_abbrev = team_map.get(event_team_id, "")

        # Get description and CSS class for this event type
        result = self._get_play_description(event_type, details, team_abbrev)
        if result is None:
            return None

        desc, css_class = result
        if not desc:
            return None

        row = Horizontal(classes="pbp-item")
        row.compose_add_child(Label(f"{time_in_period:>6}", classes="pbp-time"))
        if team_abbrev:
            row.compose_add_child(Label(team_abbrev, classes="pbp-team"))
        row.compose_add_child(Label(desc, classes=css_class))
        return row

    def _get_play_description(self, event_type: str, details: dict, team_abbrev: str = "") -> tuple[str, str] | None:
        """Get description and CSS class for a play event type."""
        handlers = {
            "goal": self._describe_goal,
            "penalty": self._describe_penalty,
            "shot-on-goal": self._describe_shot,
            "blocked-shot": self._describe_blocked_shot,
            "missed-shot": self._describe_missed_shot,
            "hit": self._describe_hit,
            "giveaway": self._describe_giveaway,
            "takeaway": self._describe_takeaway,
            "faceoff": self._describe_faceoff,
            "stoppage": self._describe_stoppage,
        }
        handler = handlers.get(event_type)
        return handler(details) if handler else None

    def _describe_goal(self, details: dict) -> tuple[str, str]:
        """Get description for a goal event."""
        scorer = self._get_player_name(details, "scoringPlayerTotal", "scoredBy")
        desc = f"GOAL - {scorer}" if scorer else "GOAL"
        assists = self._get_assists(details)
        if assists:
            desc += f" ({', '.join(assists)})"
        return (desc, "pbp-event pbp-goal")

    def _describe_penalty(self, details: dict) -> tuple[str, str]:
        """Get description for a penalty event."""
        player = self._get_player_name(details, "committedByPlayer")
        penalty_type = details.get("descKey", "penalty")
        minutes = details.get("duration", 2)
        desc = f"PENALTY - {player}: {penalty_type} ({minutes} min)" if player else f"PENALTY ({minutes} min)"
        return (desc, "pbp-event pbp-penalty")

    def _describe_hit(self, details: dict) -> tuple[str, str]:
        """Get description for a hit event."""
        hitter = self._get_player_name(details, "hittingPlayer")
        hittee = self._get_player_name(details, "hitteePlayer")
        if hitter and hittee:
            desc = f"Hit - {hitter} on {hittee}"
        elif hitter:
            desc = f"Hit - {hitter}"
        else:
            desc = "Hit"
        return (desc, "pbp-event")

    def _describe_shot(self, details: dict) -> tuple[str, str]:
        """Get description for a shot on goal event."""
        shooter = self._get_player_name(details, "shootingPlayer")
        return (f"Shot - {shooter}" if shooter else "Shot on goal", "pbp-event")

    def _describe_blocked_shot(self, details: dict) -> tuple[str, str]:
        """Get description for a blocked shot event."""
        blocker = self._get_player_name(details, "blockingPlayer")
        return (f"Blocked shot - {blocker}" if blocker else "Blocked shot", "pbp-event")

    def _describe_missed_shot(self, details: dict) -> tuple[str, str]:
        """Get description for a missed shot event."""
        shooter = self._get_player_name(details, "shootingPlayer")
        return (f"Missed shot - {shooter}" if shooter else "Missed shot", "pbp-event")

    def _describe_giveaway(self, details: dict) -> tuple[str, str]:
        """Get description for a giveaway event."""
        player = self._get_player_name(details, "playerId")
        return (f"Giveaway - {player}" if player else "Giveaway", "pbp-event")

    def _describe_takeaway(self, details: dict) -> tuple[str, str]:
        """Get description for a takeaway event."""
        player = self._get_player_name(details, "playerId")
        return (f"Takeaway - {player}" if player else "Takeaway", "pbp-event")

    def _describe_faceoff(self, details: dict) -> tuple[str, str]:
        """Get description for a faceoff event."""
        winner = self._get_player_name(details, "winningPlayer")
        return (f"Faceoff won - {winner}" if winner else "Faceoff", "pbp-event")

    def _describe_stoppage(self, details: dict) -> tuple[str, str]:
        """Get description for a stoppage event."""
        reason = details.get("reason", "")
        return (f"Stoppage - {reason}" if reason else "Stoppage", "pbp-event")

    def _get_player_name(self, details: dict, *keys: str) -> str:
        """Get player name from details using multiple possible keys."""
        for key in keys:
            if key in details:
                data = details[key]
                if isinstance(data, dict):
                    return data.get("name", {}).get("default", "")
                elif isinstance(data, str):
                    return data
        return ""

    def _get_assists(self, details: dict) -> list[str]:
        """Get assist player names from goal details."""
        assists = []
        for key in ["assist1PlayerTotal", "assist2PlayerTotal"]:
            if key in details:
                data = details[key]
                if isinstance(data, dict):
                    name = data.get("name", {}).get("default", "")
                    if name:
                        assists.append(name)
        return assists

    def _update_countdown(self) -> None:
        """Update the countdown timer every second."""
        self._countdown -= 1
        if self._countdown < 0:
            self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()

    def _update_subtitle(self) -> None:
        """Update the screen subtitle with countdown."""
        self.sub_title = f"Refreshing in {self._countdown}s"

    def _auto_refresh(self) -> None:
        """Auto-refresh game data."""
        self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()
        self.client.clear_cache()
        self.load_game_data()

    def action_back(self) -> None:
        """Go back to schedule."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh game data."""
        self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
        self._update_subtitle()
        self.client.clear_cache()
        self.load_game_data()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

action_back()

Go back to schedule.

Source code in src/faceoff/screens/game.py
795
796
797
def action_back(self) -> None:
    """Go back to schedule."""
    self.app.pop_screen()

action_quit()

Quit the application.

Source code in src/faceoff/screens/game.py
807
808
809
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh game data.

Source code in src/faceoff/screens/game.py
799
800
801
802
803
804
805
def action_refresh(self) -> None:
    """Manually refresh game data."""
    self._countdown = self.app.refresh_interval  # type: ignore[attr-defined]
    self._update_subtitle()
    self.client.clear_cache()
    self.load_game_data()
    self.notify("Refreshed")

load_game_data()

Load detailed game data.

Source code in src/faceoff/screens/game.py
302
303
304
def load_game_data(self) -> None:
    """Load detailed game data."""
    self.run_worker(self._fetch_game_data())

on_mount()

Load game data when screen is mounted.

Source code in src/faceoff/screens/game.py
269
270
271
272
273
274
275
276
277
278
279
280
def on_mount(self) -> None:
    """Load game data when screen is mounted."""
    self.load_game_data()

    # Set up auto-refresh for pre-game and live games
    game_state = self.game_data.get("gameState", "FUT")
    if game_state in ("PRE", "LIVE", "CRIT"):
        interval = self.app.refresh_interval  # type: ignore[attr-defined]
        self._countdown = interval
        self._refresh_timer = self.set_interval(interval, callback=self._auto_refresh)  # type: ignore[arg-type]
        self._countdown_timer = self.set_interval(1, callback=self._update_countdown)
        self._update_subtitle()

on_resize(event)

Handle resize to adjust layout.

Source code in src/faceoff/screens/game.py
289
290
291
292
293
294
295
296
297
298
299
300
def on_resize(self, event) -> None:
    """Handle resize to adjust layout."""
    try:
        content = self.query_one("#game-content", VerticalScroll)
        new_width = content.size.width
    except Exception:
        return

    # Only re-layout if width changed significantly
    if abs(new_width - self._last_width) >= 20:
        self._last_width = new_width
        self._update_main_content()

on_unmount()

Clean up when screen is unmounted.

Source code in src/faceoff/screens/game.py
282
283
284
285
286
287
def on_unmount(self) -> None:
    """Clean up when screen is unmounted."""
    if self._refresh_timer:
        self._refresh_timer.stop()
    if self._countdown_timer:
        self._countdown_timer.stop()

Pre-game screen for viewing game matchup preview.

PreGameScreen

Bases: Screen

Screen for viewing pre-game matchup information.

Source code in src/faceoff/screens/pregame.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
class PreGameScreen(Screen):
    """Screen for viewing pre-game matchup information."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
    ]

    DEFAULT_CSS = """
    PreGameScreen {
        background: $surface;
    }

    PreGameScreen .pregame-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    PreGameScreen .matchup-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    PreGameScreen .game-info {
        width: 100%;
        height: auto;
        align: center middle;
        padding: 1;
        margin-bottom: 1;
    }

    PreGameScreen .game-time {
        text-style: bold;
        text-align: center;
        width: 100%;
    }

    PreGameScreen .venue {
        text-align: center;
        width: 100%;
        color: $text-muted;
    }

    PreGameScreen .teams-row {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }

    PreGameScreen .team-panel {
        width: 1fr;
        height: auto;
        border: solid $primary;
        padding: 1;
        margin: 0 1;
    }

    PreGameScreen .team-name {
        text-style: bold;
        text-align: center;
        width: 100%;
        margin-bottom: 1;
    }

    PreGameScreen .team-record {
        text-align: center;
        width: 100%;
        color: $text-muted;
        margin-bottom: 1;
    }

    PreGameScreen .section-header {
        text-style: bold;
        width: 100%;
        background: $surface-lighten-1;
        padding: 0 1;
        margin-top: 1;
    }

    PreGameScreen .goalie-row {
        width: 100%;
        height: auto;
        padding: 0 1;
    }

    PreGameScreen .goalie-name {
        width: 1fr;
    }

    PreGameScreen .goalie-stats {
        width: auto;
        text-align: right;
        color: $text-muted;
    }

    PreGameScreen .comparison-section {
        width: 100%;
        height: auto;
        border: solid $primary;
        padding: 1;
        margin-top: 1;
    }

    PreGameScreen .comparison-header {
        text-style: bold;
        text-align: center;
        width: 100%;
        margin-bottom: 1;
    }

    PreGameScreen .comparison-row {
        width: 100%;
        height: 1;
    }

    PreGameScreen .comp-away {
        width: 1fr;
        text-align: left;
    }

    PreGameScreen .comp-category {
        width: 12;
        text-align: center;
        text-style: bold;
    }

    PreGameScreen .comp-home {
        width: 1fr;
        text-align: right;
    }

    PreGameScreen .vs-label {
        text-align: center;
        width: auto;
        padding: 0 2;
    }

    PreGameScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }
    """

    def __init__(self, client: NHLClient, game_id: int, game_data: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.game_id = game_id
        self.game_data = game_data
        self.landing: dict = {}

    def compose(self) -> ComposeResult:
        away = self.game_data.get("awayTeam", {}).get("abbrev", "???")
        home = self.game_data.get("homeTeam", {}).get("abbrev", "???")
        yield Header()
        yield Static(f"Pre-Game: {away} @ {home}", classes="pregame-header")
        with VerticalScroll(classes="matchup-container", id="matchup-container"):
            yield Label("Loading matchup data...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load matchup data when screen is mounted."""
        self.load_matchup_data()

    def load_matchup_data(self) -> None:
        """Load matchup data from API."""
        self.run_worker(self._fetch_matchup_data())

    async def _fetch_matchup_data(self) -> None:
        """Fetch matchup data from the API."""
        try:
            self.landing = await self.client.get_game_landing(self.game_id)
            self._update_matchup_view()
        except Exception as e:
            self.notify(f"Error loading matchup: {e}", severity="error")

    def _update_matchup_view(self) -> None:
        """Update the matchup view with loaded data."""
        container = self.query_one("#matchup-container", VerticalScroll)
        container.remove_children()

        if not self.landing:
            container.mount(Label("No matchup data available"))
            return

        # Game info section
        game_info = Vertical(classes="game-info")
        start_time = self.landing.get("startTimeUTC", "")
        local_time = get_local_time_with_tz(start_time)
        venue = self.landing.get("venue", {}).get("default", "")
        venue_loc = self.landing.get("venueLocation", {}).get("default", "")

        game_info.compose_add_child(Static(f"Game Time: {local_time}", classes="game-time"))
        if venue:
            venue_text = f"{venue}, {venue_loc}" if venue_loc else venue
            game_info.compose_add_child(Static(venue_text, classes="venue"))
        container.mount(game_info)

        # Team panels
        teams_row = Horizontal(classes="teams-row")
        away_team = self.landing.get("awayTeam", {})
        home_team = self.landing.get("homeTeam", {})

        # Away team panel
        away_panel = self._create_team_panel(away_team, is_home=False)
        teams_row.compose_add_child(away_panel)

        # VS label
        vs_label = Static("@", classes="vs-label")
        teams_row.compose_add_child(vs_label)

        # Home team panel
        home_panel = self._create_team_panel(home_team, is_home=True)
        teams_row.compose_add_child(home_panel)

        container.mount(teams_row)

        # Matchup comparison section
        matchup = self.landing.get("matchup", {})
        if matchup:
            # Goalie comparison
            goalie_comp = matchup.get("goalieComparison", {})
            if goalie_comp:
                comp_section = self._create_goalie_comparison(goalie_comp)
                container.mount(comp_section)

            # Skater comparison (leaders)
            skater_comp = matchup.get("skaterComparison", {})
            if skater_comp:
                leaders = skater_comp.get("leaders", [])
                if leaders:
                    skater_section = self._create_skater_comparison(leaders)
                    container.mount(skater_section)

    def _create_team_panel(self, team: dict, is_home: bool) -> Vertical:
        """Create a team info panel."""
        panel = Vertical(classes="team-panel")

        name = team.get("commonName", {})
        if isinstance(name, dict):
            name = name.get("default", team.get("abbrev", "???"))
        abbrev = team.get("abbrev", "")
        record = team.get("record", "")

        panel.compose_add_child(Static(f"{name} ({abbrev})", classes="team-name"))
        if record:
            panel.compose_add_child(Static(f"Record: {record}", classes="team-record"))

        return panel

    def _create_goalie_comparison(self, goalie_comp: dict) -> Vertical:
        """Create goalie comparison section."""
        section = Vertical(classes="comparison-section")
        section.compose_add_child(Static("Goalie Matchup", classes="comparison-header"))

        away_team = goalie_comp.get("awayTeam", {})
        home_team = goalie_comp.get("homeTeam", {})

        away_goalies = away_team.get("leaders", [])
        home_goalies = home_team.get("leaders", [])

        # Show team goalie totals
        away_totals = away_team.get("teamTotals", {})
        home_totals = home_team.get("teamTotals", {})

        if away_totals or home_totals:
            row = Horizontal(classes="comparison-row")
            away_rec = away_totals.get("record", "-")
            home_rec = home_totals.get("record", "-")
            away_sv = away_totals.get("savePctg", 0)
            home_sv = home_totals.get("savePctg", 0)

            row.compose_add_child(Label(f"{away_rec} | SV%: {away_sv:.3f}", classes="comp-away"))
            row.compose_add_child(Label("Team", classes="comp-category"))
            row.compose_add_child(Label(f"SV%: {home_sv:.3f} | {home_rec}", classes="comp-home"))
            section.compose_add_child(row)

        # Show individual goalies
        max_goalies = max(len(away_goalies), len(home_goalies))
        for i in range(min(max_goalies, 2)):  # Show up to 2 goalies per team
            row = Horizontal(classes="comparison-row")

            if i < len(away_goalies):
                g = away_goalies[i]
                name = g.get("name", {}).get("default", "?")
                record = g.get("record", "-")
                gaa = g.get("gaa", 0)
                row.compose_add_child(Label(f"{name} ({record}, {gaa:.2f})", classes="comp-away"))
            else:
                row.compose_add_child(Label("", classes="comp-away"))

            row.compose_add_child(Label(f"G{i + 1}", classes="comp-category"))

            if i < len(home_goalies):
                g = home_goalies[i]
                name = g.get("name", {}).get("default", "?")
                record = g.get("record", "-")
                gaa = g.get("gaa", 0)
                row.compose_add_child(Label(f"({gaa:.2f}, {record}) {name}", classes="comp-home"))
            else:
                row.compose_add_child(Label("", classes="comp-home"))

            section.compose_add_child(row)

        return section

    def _create_skater_comparison(self, leaders: list) -> Vertical:
        """Create skater leaders comparison section."""
        section = Vertical(classes="comparison-section")
        section.compose_add_child(Static("Skater Leaders (Last 5 Games)", classes="comparison-header"))

        for leader in leaders[:5]:  # Show top 5 categories
            category = leader.get("category", "?")
            away_leader = leader.get("awayLeader", {})
            home_leader = leader.get("homeLeader", {})

            row = Horizontal(classes="comparison-row")

            # Away leader
            if away_leader:
                name = away_leader.get("name", {}).get("default", "?")
                value = away_leader.get("value", 0)
                row.compose_add_child(Label(f"{name}: {value}", classes="comp-away"))
            else:
                row.compose_add_child(Label("-", classes="comp-away"))

            # Category
            cat_display = category.replace("_", " ").title()
            row.compose_add_child(Label(cat_display[:10], classes="comp-category"))

            # Home leader
            if home_leader:
                name = home_leader.get("name", {}).get("default", "?")
                value = home_leader.get("value", 0)
                row.compose_add_child(Label(f"{value}: {name}", classes="comp-home"))
            else:
                row.compose_add_child(Label("-", classes="comp-home"))

            section.compose_add_child(row)

        return section

    def action_back(self) -> None:
        """Go back to schedule."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh matchup data."""
        self.client.clear_cache()
        self.load_matchup_data()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

action_back()

Go back to schedule.

Source code in src/faceoff/screens/pregame.py
362
363
364
def action_back(self) -> None:
    """Go back to schedule."""
    self.app.pop_screen()

action_quit()

Quit the application.

Source code in src/faceoff/screens/pregame.py
372
373
374
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh matchup data.

Source code in src/faceoff/screens/pregame.py
366
367
368
369
370
def action_refresh(self) -> None:
    """Manually refresh matchup data."""
    self.client.clear_cache()
    self.load_matchup_data()
    self.notify("Refreshed")

load_matchup_data()

Load matchup data from API.

Source code in src/faceoff/screens/pregame.py
184
185
186
def load_matchup_data(self) -> None:
    """Load matchup data from API."""
    self.run_worker(self._fetch_matchup_data())

on_mount()

Load matchup data when screen is mounted.

Source code in src/faceoff/screens/pregame.py
180
181
182
def on_mount(self) -> None:
    """Load matchup data when screen is mounted."""
    self.load_matchup_data()

Standings screen for viewing league standings.

StandingsScreen

Bases: Screen

Screen for viewing NHL standings.

Source code in src/faceoff/screens/standings.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
class StandingsScreen(Screen):
    """Screen for viewing NHL standings."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
        Binding("up,k", "scroll_up", "Scroll Up", show=False),
        Binding("down,j", "scroll_down", "Scroll Down", show=False),
    ]

    # Minimum width to display conferences side-by-side
    # Each conference needs ~70 chars (rank:4 + name:24 + gp:6 + w:6 + l:6 + otl:6 + pts:6 + pct:8 + padding)
    # Two conferences side-by-side need ~150 chars total
    SIDE_BY_SIDE_MIN_WIDTH = 150

    DEFAULT_CSS = """
    StandingsScreen {
        background: $surface;
    }

    StandingsScreen .standings-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    StandingsScreen .standings-tabs {
        width: 100%;
        height: 1fr;
    }

    StandingsScreen .standings-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    StandingsScreen .conference-row {
        width: 100%;
        height: auto;
    }

    StandingsScreen .conference-section {
        width: 1fr;
        height: auto;
        padding: 0 1;
    }

    StandingsScreen .conference-header {
        width: 100%;
        height: 1;
        text-style: bold;
        text-align: center;
        background: $primary;
        margin-bottom: 1;
    }

    StandingsScreen .division-section {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }

    StandingsScreen .division-header {
        width: 100%;
        height: 1;
        text-style: bold;
        background: $surface-lighten-1;
        padding: 0 1;
    }

    StandingsScreen .team-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    StandingsScreen .team-row:hover {
        background: $surface-lighten-2;
    }

    StandingsScreen .team-rank {
        width: 4;
        text-align: right;
    }

    StandingsScreen .team-name {
        width: 24;
        padding-left: 1;
    }

    StandingsScreen .team-gp {
        width: 6;
        text-align: center;
    }

    StandingsScreen .team-wins {
        width: 6;
        text-align: center;
    }

    StandingsScreen .team-losses {
        width: 6;
        text-align: center;
    }

    StandingsScreen .team-otl {
        width: 6;
        text-align: center;
    }

    StandingsScreen .team-points {
        width: 6;
        text-align: center;
        text-style: bold;
    }

    StandingsScreen .team-pct {
        width: 8;
        text-align: center;
    }

    StandingsScreen .header-row {
        width: 100%;
        height: 1;
        padding: 0 1;
        text-style: bold;
        color: $text-muted;
    }

    StandingsScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }

    StandingsScreen .wild-card-header {
        width: 100%;
        height: 1;
        text-style: bold italic;
        background: $warning 20%;
        padding: 0 1;
        margin-top: 1;
    }

    StandingsScreen .league-section {
        width: 100%;
        height: auto;
        padding: 0 1;
    }
    """

    def __init__(self, client: NHLClient, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.standings: list = []
        self._last_width: int = 0

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("NHL Standings", classes="standings-header")
        with TabbedContent(classes="standings-tabs"):
            with (
                TabPane("Wild Card", id="tab-wildcard"),
                VerticalScroll(id="wildcard-container", classes="standings-container"),
            ):
                yield Label("Loading...", classes="loading")
            with (
                TabPane("Division", id="tab-division"),
                VerticalScroll(id="division-container", classes="standings-container"),
            ):
                yield Label("Loading...", classes="loading")
            with (
                TabPane("Conference", id="tab-conference"),
                VerticalScroll(id="conference-container", classes="standings-container"),
            ):
                yield Label("Loading...", classes="loading")
            with (
                TabPane("League", id="tab-league"),
                VerticalScroll(id="league-container", classes="standings-container"),
            ):
                yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load standings when screen is mounted."""
        self.load_standings()

    def load_standings(self) -> None:
        """Load standings from API."""
        self.run_worker(self._fetch_standings())

    async def _fetch_standings(self) -> None:
        """Fetch standings from the API."""
        try:
            data = await self.client.get_standings()
            self.standings = data.get("standings", [])
            self._update_all_views()
        except Exception as e:
            self.notify(f"Error loading standings: {e}", severity="error")

    def _update_all_views(self) -> None:
        """Update all standings views."""
        self._update_wildcard_view()
        self._update_division_view()
        self._update_conference_view()
        self._update_league_view()

    def _update_wildcard_view(self) -> None:  # noqa: C901
        """Update the wild card standings view."""
        container = self.query_one("#wildcard-container", VerticalScroll)
        container.remove_children()

        if not self.standings:
            container.mount(Label("No standings data available"))
            return

        # Group by conference and division
        conferences: dict[str, dict[str, list]] = {}
        wild_cards: dict[str, list] = {}

        for team in self.standings:
            conf_name = team.get("conferenceName", "Unknown")
            div_name = team.get("divisionName", "Unknown")

            if conf_name not in conferences:
                conferences[conf_name] = {}
                wild_cards[conf_name] = []

            if div_name not in conferences[conf_name]:
                conferences[conf_name][div_name] = []

            div_rank = team.get("divisionSequence", 0)
            if div_rank <= 3:
                conferences[conf_name][div_name].append(team)
            else:
                wild_cards[conf_name].append(team)

        # Create conference sections
        conf_sections = []
        for conf_name in sorted(conferences.keys()):
            conf_section = Vertical(classes="conference-section")
            conf_section.compose_add_child(Static(f"{conf_name} Conference", classes="conference-header"))
            conf_section.compose_add_child(self._create_header_row())

            for div_name in sorted(conferences[conf_name].keys()):
                div_section = Vertical(classes="division-section")
                div_section.compose_add_child(Static(div_name, classes="division-header"))

                teams = sorted(conferences[conf_name][div_name], key=lambda t: t.get("divisionSequence", 99))
                for team in teams:
                    div_section.compose_add_child(self._create_team_row(team))

                conf_section.compose_add_child(div_section)

            if wild_cards[conf_name]:
                conf_section.compose_add_child(Static("Wild Card", classes="wild-card-header"))
                wc_teams = sorted(wild_cards[conf_name], key=lambda t: t.get("wildcardSequence", 99))
                for team in wc_teams:
                    conf_section.compose_add_child(self._create_team_row(team, is_wild_card=True))

            conf_sections.append(conf_section)

        # Render side-by-side or stacked based on terminal width
        if self._is_wide_enough() and len(conf_sections) == 2:
            row = Horizontal(classes="conference-row")
            for section in conf_sections:
                row.compose_add_child(section)
            container.mount(row)
        else:
            for section in conf_sections:
                container.mount(section)

    def _update_division_view(self) -> None:  # noqa: C901
        """Update the division standings view."""
        container = self.query_one("#division-container", VerticalScroll)
        container.remove_children()

        if not self.standings:
            container.mount(Label("No standings data available"))
            return

        # Group by conference and division
        conferences: dict[str, dict[str, list]] = {}
        for team in self.standings:
            conf_name = team.get("conferenceName", "Unknown")
            div_name = team.get("divisionName", "Unknown")
            if conf_name not in conferences:
                conferences[conf_name] = {}
            if div_name not in conferences[conf_name]:
                conferences[conf_name][div_name] = []
            conferences[conf_name][div_name].append(team)

        # Create conference sections (each with its divisions)
        conf_sections = []
        for conf_name in sorted(conferences.keys()):
            conf_section = Vertical(classes="conference-section")

            for div_name in sorted(conferences[conf_name].keys()):
                div_section = Vertical(classes="division-section")
                div_section.compose_add_child(Static(div_name, classes="conference-header"))
                div_section.compose_add_child(self._create_header_row())

                teams = sorted(conferences[conf_name][div_name], key=lambda t: t.get("divisionSequence", 99))
                for i, team in enumerate(teams, 1):
                    div_section.compose_add_child(self._create_team_row(team, rank=i))

                conf_section.compose_add_child(div_section)

            conf_sections.append(conf_section)

        # Render side-by-side or stacked based on terminal width
        if self._is_wide_enough() and len(conf_sections) == 2:
            row = Horizontal(classes="conference-row")
            for section in conf_sections:
                row.compose_add_child(section)
            container.mount(row)
        else:
            for section in conf_sections:
                container.mount(section)

    def _update_conference_view(self) -> None:
        """Update the conference standings view."""
        container = self.query_one("#conference-container", VerticalScroll)
        container.remove_children()

        if not self.standings:
            container.mount(Label("No standings data available"))
            return

        # Group by conference
        conferences: dict[str, list] = {}
        for team in self.standings:
            conf_name = team.get("conferenceName", "Unknown")
            if conf_name not in conferences:
                conferences[conf_name] = []
            conferences[conf_name].append(team)

        # Create conference sections
        conf_sections = []
        for conf_name in sorted(conferences.keys()):
            conf_section = Vertical(classes="conference-section")
            conf_section.compose_add_child(Static(f"{conf_name} Conference", classes="conference-header"))
            conf_section.compose_add_child(self._create_header_row())

            teams = sorted(conferences[conf_name], key=lambda t: t.get("conferenceSequence", 99))
            for i, team in enumerate(teams, 1):
                conf_section.compose_add_child(self._create_team_row(team, rank=i))

            conf_sections.append(conf_section)

        # Render side-by-side or stacked based on terminal width
        if self._is_wide_enough() and len(conf_sections) == 2:
            row = Horizontal(classes="conference-row")
            for section in conf_sections:
                row.compose_add_child(section)
            container.mount(row)
        else:
            for section in conf_sections:
                container.mount(section)

    def _update_league_view(self) -> None:
        """Update the league-wide standings view."""
        container = self.query_one("#league-container", VerticalScroll)
        container.remove_children()

        if not self.standings:
            container.mount(Label("No standings data available"))
            return

        league_section = Vertical(classes="league-section")
        league_section.compose_add_child(Static("NHL Standings", classes="conference-header"))
        league_section.compose_add_child(self._create_header_row())

        teams = sorted(self.standings, key=lambda t: t.get("leagueSequence", 99))
        for i, team in enumerate(teams, 1):
            league_section.compose_add_child(self._create_team_row(team, rank=i))

        container.mount(league_section)

    def _create_header_row(self) -> Horizontal:
        """Create the header row for standings."""
        row = Horizontal(classes="header-row")
        row.compose_add_child(Label("#", classes="team-rank"))
        row.compose_add_child(Label("Team", classes="team-name"))
        row.compose_add_child(Label("GP", classes="team-gp"))
        row.compose_add_child(Label("W", classes="team-wins"))
        row.compose_add_child(Label("L", classes="team-losses"))
        row.compose_add_child(Label("OTL", classes="team-otl"))
        row.compose_add_child(Label("PTS", classes="team-points"))
        row.compose_add_child(Label("PCT", classes="team-pct"))
        return row

    def _create_team_row(self, team: dict, is_wild_card: bool = False, rank: int | None = None) -> Horizontal:
        """Create a row for a single team."""
        row = Horizontal(classes="team-row")

        if rank is not None:
            display_rank = rank
        elif is_wild_card:
            display_rank = team.get("wildcardSequence", "-")
        else:
            display_rank = team.get("divisionSequence", "-")

        team_name = team.get("teamAbbrev", {}).get("default", "???")
        gp = team.get("gamesPlayed", 0)
        wins = team.get("wins", 0)
        losses = team.get("losses", 0)
        otl = team.get("otLosses", 0)
        points = team.get("points", 0)
        pct = team.get("pointPctg", 0)

        row.compose_add_child(Label(str(display_rank), classes="team-rank"))
        row.compose_add_child(Label(team_name, classes="team-name"))
        row.compose_add_child(Label(str(gp), classes="team-gp"))
        row.compose_add_child(Label(str(wins), classes="team-wins"))
        row.compose_add_child(Label(str(losses), classes="team-losses"))
        row.compose_add_child(Label(str(otl), classes="team-otl"))
        row.compose_add_child(Label(str(points), classes="team-points"))
        row.compose_add_child(Label(f"{pct:.3f}", classes="team-pct"))

        return row

    def _get_active_container(self) -> VerticalScroll | None:
        """Get the currently active tab's scroll container."""
        try:
            tabs = self.query_one(TabbedContent)
            active_tab = tabs.active
            if active_tab == "tab-wildcard":
                return self.query_one("#wildcard-container", VerticalScroll)
            elif active_tab == "tab-division":
                return self.query_one("#division-container", VerticalScroll)
            elif active_tab == "tab-conference":
                return self.query_one("#conference-container", VerticalScroll)
            elif active_tab == "tab-league":
                return self.query_one("#league-container", VerticalScroll)
        except Exception:
            return None
        return None

    def _is_wide_enough(self) -> bool:
        """Check if screen is wide enough for side-by-side layout."""
        return self.size.width >= self.SIDE_BY_SIDE_MIN_WIDTH

    def on_resize(self, event) -> None:
        """Handle terminal resize to reflow layout."""
        if not self.standings:
            return
        new_width = self.size.width
        # Check if we crossed the threshold
        was_wide = self._last_width >= self.SIDE_BY_SIDE_MIN_WIDTH
        is_wide = new_width >= self.SIDE_BY_SIDE_MIN_WIDTH
        if was_wide != is_wide:
            self._last_width = new_width
            self._update_all_views()

    def action_scroll_up(self) -> None:
        """Scroll the active standings container up."""
        container = self._get_active_container()
        if container:
            container.scroll_up(animate=False)

    def action_scroll_down(self) -> None:
        """Scroll the active standings container down."""
        container = self._get_active_container()
        if container:
            container.scroll_down(animate=False)

    def action_back(self) -> None:
        """Go back to schedule."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh standings."""
        self.client.clear_cache()
        self.load_standings()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

action_back()

Go back to schedule.

Source code in src/faceoff/screens/standings.py
485
486
487
def action_back(self) -> None:
    """Go back to schedule."""
    self.app.pop_screen()

action_quit()

Quit the application.

Source code in src/faceoff/screens/standings.py
495
496
497
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh standings.

Source code in src/faceoff/screens/standings.py
489
490
491
492
493
def action_refresh(self) -> None:
    """Manually refresh standings."""
    self.client.clear_cache()
    self.load_standings()
    self.notify("Refreshed")

action_scroll_down()

Scroll the active standings container down.

Source code in src/faceoff/screens/standings.py
479
480
481
482
483
def action_scroll_down(self) -> None:
    """Scroll the active standings container down."""
    container = self._get_active_container()
    if container:
        container.scroll_down(animate=False)

action_scroll_up()

Scroll the active standings container up.

Source code in src/faceoff/screens/standings.py
473
474
475
476
477
def action_scroll_up(self) -> None:
    """Scroll the active standings container up."""
    container = self._get_active_container()
    if container:
        container.scroll_up(animate=False)

load_standings()

Load standings from API.

Source code in src/faceoff/screens/standings.py
205
206
207
def load_standings(self) -> None:
    """Load standings from API."""
    self.run_worker(self._fetch_standings())

on_mount()

Load standings when screen is mounted.

Source code in src/faceoff/screens/standings.py
201
202
203
def on_mount(self) -> None:
    """Load standings when screen is mounted."""
    self.load_standings()

on_resize(event)

Handle terminal resize to reflow layout.

Source code in src/faceoff/screens/standings.py
461
462
463
464
465
466
467
468
469
470
471
def on_resize(self, event) -> None:
    """Handle terminal resize to reflow layout."""
    if not self.standings:
        return
    new_width = self.size.width
    # Check if we crossed the threshold
    was_wide = self._last_width >= self.SIDE_BY_SIDE_MIN_WIDTH
    is_wide = new_width >= self.SIDE_BY_SIDE_MIN_WIDTH
    if was_wide != is_wide:
        self._last_width = new_width
        self._update_all_views()

Stats screen for viewing player statistics.

StatsScreen

Bases: Screen

Screen for viewing player stats leaders.

Source code in src/faceoff/screens/stats.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
class StatsScreen(Screen):
    """Screen for viewing player stats leaders."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
        Binding("up,k", "scroll_up", "Scroll Up", show=False),
        Binding("down,j", "scroll_down", "Scroll Down", show=False),
    ]

    DEFAULT_CSS = """
    StatsScreen {
        background: $surface;
    }

    StatsScreen .stats-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    StatsScreen .stats-tabs {
        width: 100%;
        height: 1fr;
    }

    StatsScreen .stats-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    StatsScreen .category-section {
        width: 100%;
        height: auto;
        margin-bottom: 2;
    }

    StatsScreen .category-header {
        width: 100%;
        height: 1;
        text-style: bold;
        text-align: center;
        background: $primary;
        margin-bottom: 1;
    }

    StatsScreen .stat-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    StatsScreen .stat-row:hover {
        background: $surface-lighten-2;
    }

    StatsScreen .stat-rank {
        width: 4;
        text-align: right;
    }

    StatsScreen .stat-player {
        width: 24;
        padding-left: 1;
    }

    StatsScreen .stat-team {
        width: 6;
        text-align: center;
    }

    StatsScreen .stat-pos {
        width: 4;
        text-align: center;
    }

    StatsScreen .stat-value {
        width: 8;
        text-align: right;
        text-style: bold;
    }

    StatsScreen .header-row {
        width: 100%;
        height: 1;
        padding: 0 1;
        text-style: bold;
        color: $text-muted;
    }

    StatsScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }

    StatsScreen .categories-row {
        width: 100%;
        height: auto;
    }

    StatsScreen .category-col {
        width: 1fr;
        height: auto;
        padding: 0 1;
    }
    """

    def __init__(self, client: NHLClient, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.skater_stats: dict = {}
        self.goalie_stats: dict = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("NHL Stats Leaders", classes="stats-header")
        with TabbedContent(classes="stats-tabs"):
            with (
                TabPane("Skaters", id="tab-skaters"),
                VerticalScroll(id="skaters-container", classes="stats-container"),
            ):
                yield Label("Loading...", classes="loading")
            with (
                TabPane("Goalies", id="tab-goalies"),
                VerticalScroll(id="goalies-container", classes="stats-container"),
            ):
                yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load stats when screen is mounted."""
        self.load_stats()

    def load_stats(self) -> None:
        """Load stats from API."""
        self.run_worker(self._fetch_stats())

    async def _fetch_stats(self) -> None:
        """Fetch stats from the API."""
        try:
            self.skater_stats = await self.client.get_skater_stats_leaders()
            self.goalie_stats = await self.client.get_goalie_stats_leaders()
            self._update_skaters_view()
            self._update_goalies_view()
        except Exception as e:
            self.notify(f"Error loading stats: {e}", severity="error")

    def _update_skaters_view(self) -> None:
        """Update the skaters stats view."""
        container = self.query_one("#skaters-container", VerticalScroll)
        container.remove_children()

        if not self.skater_stats:
            container.mount(Label("No stats data available"))
            return

        # Categories to display with their display names
        categories = [
            ("goals", "Goals"),
            ("assists", "Assists"),
            ("points", "Points"),
            ("plusMinus", "+/-"),
            ("goalsPp", "PP Goals"),
            ("goalsSh", "SH Goals"),
            ("penaltyMins", "PIM"),
            ("toi", "TOI/G"),
        ]

        # Create two-column layout
        row = Horizontal(classes="categories-row")

        left_col = Vertical(classes="category-col")
        right_col = Vertical(classes="category-col")

        for i, (key, display_name) in enumerate(categories):
            if key in self.skater_stats:
                section = self._create_category_section(display_name, self.skater_stats[key][:5], key)
                if i % 2 == 0:
                    left_col.compose_add_child(section)
                else:
                    right_col.compose_add_child(section)

        row.compose_add_child(left_col)
        row.compose_add_child(right_col)
        container.mount(row)

    def _update_goalies_view(self) -> None:
        """Update the goalies stats view."""
        container = self.query_one("#goalies-container", VerticalScroll)
        container.remove_children()

        if not self.goalie_stats:
            container.mount(Label("No goalie stats available"))
            return

        # Categories to display
        categories = [
            ("wins", "Wins"),
            ("savePctg", "Save %"),
            ("goalsAgainstAverage", "GAA"),
            ("shutouts", "Shutouts"),
        ]

        row = Horizontal(classes="categories-row")
        left_col = Vertical(classes="category-col")
        right_col = Vertical(classes="category-col")

        for i, (key, display_name) in enumerate(categories):
            if key in self.goalie_stats:
                section = self._create_category_section(display_name, self.goalie_stats[key][:5], key, is_goalie=True)
                if i % 2 == 0:
                    left_col.compose_add_child(section)
                else:
                    right_col.compose_add_child(section)

        row.compose_add_child(left_col)
        row.compose_add_child(right_col)
        container.mount(row)

    def _create_category_section(self, title: str, players: list, stat_key: str, is_goalie: bool = False) -> Vertical:
        """Create a section for a stat category."""
        section = Vertical(classes="category-section")
        section.compose_add_child(Static(title, classes="category-header"))

        # Header row
        header = Horizontal(classes="header-row")
        header.compose_add_child(Label("#", classes="stat-rank"))
        header.compose_add_child(Label("Player", classes="stat-player"))
        header.compose_add_child(Label("Team", classes="stat-team"))
        header.compose_add_child(Label("Pos", classes="stat-pos"))
        header.compose_add_child(Label("Value", classes="stat-value"))
        section.compose_add_child(header)

        for i, player in enumerate(players, 1):
            row = self._create_player_row(i, player, stat_key)
            section.compose_add_child(row)

        return section

    def _create_player_row(self, rank: int, player: dict, stat_key: str) -> Horizontal:
        """Create a row for a player."""
        row = Horizontal(classes="stat-row")

        first_name = player.get("firstName", {}).get("default", "")
        last_name = player.get("lastName", {}).get("default", "")
        name = f"{first_name[0]}. {last_name}" if first_name else last_name
        team = player.get("teamAbbrev", "???")
        pos = player.get("position", "?")
        value = player.get("value", 0)

        # Format value based on stat type
        if stat_key in ("savePctg",):
            value_str = f"{value:.3f}"
        elif stat_key in ("goalsAgainstAverage",):
            value_str = f"{value:.2f}"
        elif stat_key == "toi":
            # TOI is in seconds, convert to MM:SS
            mins = int(value) // 60
            secs = int(value) % 60
            value_str = f"{mins}:{secs:02d}"
        else:
            value_str = str(value)

        row.compose_add_child(Label(str(rank), classes="stat-rank"))
        row.compose_add_child(Label(name[:22], classes="stat-player"))
        row.compose_add_child(Label(team, classes="stat-team"))
        row.compose_add_child(Label(pos, classes="stat-pos"))
        row.compose_add_child(Label(value_str, classes="stat-value"))

        return row

    def action_back(self) -> None:
        """Go back to schedule."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh stats."""
        self.client.clear_cache()
        self.load_stats()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

    def _get_active_scroll_container(self) -> VerticalScroll | None:
        """Get the currently active scroll container based on selected tab."""
        try:
            tabbed = self.query_one(TabbedContent)
            active_tab = tabbed.active
            if active_tab == "tab-skaters":
                return self.query_one("#skaters-container", VerticalScroll)
            elif active_tab == "tab-goalies":
                return self.query_one("#goalies-container", VerticalScroll)
        except Exception:
            return None
        return None

    def action_scroll_up(self) -> None:
        """Scroll the active container up."""
        container = self._get_active_scroll_container()
        if container:
            container.scroll_up(animate=False)

    def action_scroll_down(self) -> None:
        """Scroll the active container down."""
        container = self._get_active_scroll_container()
        if container:
            container.scroll_down(animate=False)

action_back()

Go back to schedule.

Source code in src/faceoff/screens/stats.py
290
291
292
def action_back(self) -> None:
    """Go back to schedule."""
    self.app.pop_screen()

action_quit()

Quit the application.

Source code in src/faceoff/screens/stats.py
300
301
302
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh stats.

Source code in src/faceoff/screens/stats.py
294
295
296
297
298
def action_refresh(self) -> None:
    """Manually refresh stats."""
    self.client.clear_cache()
    self.load_stats()
    self.notify("Refreshed")

action_scroll_down()

Scroll the active container down.

Source code in src/faceoff/screens/stats.py
323
324
325
326
327
def action_scroll_down(self) -> None:
    """Scroll the active container down."""
    container = self._get_active_scroll_container()
    if container:
        container.scroll_down(animate=False)

action_scroll_up()

Scroll the active container up.

Source code in src/faceoff/screens/stats.py
317
318
319
320
321
def action_scroll_up(self) -> None:
    """Scroll the active container up."""
    container = self._get_active_scroll_container()
    if container:
        container.scroll_up(animate=False)

load_stats()

Load stats from API.

Source code in src/faceoff/screens/stats.py
152
153
154
def load_stats(self) -> None:
    """Load stats from API."""
    self.run_worker(self._fetch_stats())

on_mount()

Load stats when screen is mounted.

Source code in src/faceoff/screens/stats.py
148
149
150
def on_mount(self) -> None:
    """Load stats when screen is mounted."""
    self.load_stats()

Teams screen for browsing teams and viewing team details.

PlayerRow

Bases: Widget

A clickable row for a player.

Source code in src/faceoff/screens/teams.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
class PlayerRow(Widget):
    """A clickable row for a player."""

    DEFAULT_CSS = """
    PlayerRow {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    PlayerRow:hover {
        background: $surface-lighten-2;
    }

    PlayerRow:focus {
        background: $primary 30%;
    }

    PlayerRow .player-number {
        width: 4;
        text-align: right;
    }

    PlayerRow .player-name {
        width: 1fr;
        padding-left: 1;
    }

    PlayerRow .player-pos {
        width: 4;
        text-align: center;
    }
    """

    can_focus = True

    class Selected(Message):
        """Message sent when a player is selected."""

        def __init__(self, player_id: int, player_name: str) -> None:
            self.player_id = player_id
            self.player_name = player_name
            super().__init__()

    def __init__(self, player_data: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.player_data = player_data
        self.player_id = player_data.get("id", 0)

    def compose(self) -> ComposeResult:
        number = self.player_data.get("sweaterNumber", "-")
        first_name = self.player_data.get("firstName", {}).get("default", "")
        last_name = self.player_data.get("lastName", {}).get("default", "")
        pos = self.player_data.get("positionCode", "?")

        with Horizontal():
            yield Label(str(number), classes="player-number")
            yield Label(f"{first_name} {last_name}", classes="player-name")
            yield Label(pos, classes="player-pos")

    def on_click(self) -> None:
        first_name = self.player_data.get("firstName", {}).get("default", "")
        last_name = self.player_data.get("lastName", {}).get("default", "")
        self.post_message(self.Selected(self.player_id, f"{first_name} {last_name}"))

    def on_key(self, event) -> None:
        if event.key == "enter":
            first_name = self.player_data.get("firstName", {}).get("default", "")
            last_name = self.player_data.get("lastName", {}).get("default", "")
            self.post_message(self.Selected(self.player_id, f"{first_name} {last_name}"))
            event.stop()

Selected

Bases: Message

Message sent when a player is selected.

Source code in src/faceoff/screens/teams.py
110
111
112
113
114
115
116
class Selected(Message):
    """Message sent when a player is selected."""

    def __init__(self, player_id: int, player_name: str) -> None:
        self.player_id = player_id
        self.player_name = player_name
        super().__init__()

TeamCard

Bases: Widget

A card widget for selecting a team.

Source code in src/faceoff/screens/teams.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class TeamCard(Widget):
    """A card widget for selecting a team."""

    DEFAULT_CSS = """
    TeamCard {
        width: 12;
        height: 3;
        border: solid $primary;
        padding: 0 1;
        margin: 0 1 1 0;
        content-align: center middle;
    }

    TeamCard:hover {
        border: solid $secondary;
    }

    TeamCard:focus {
        border: double $accent;
    }

    TeamCard .team-abbrev {
        text-align: center;
        text-style: bold;
    }
    """

    can_focus = True

    class Selected(Message):
        """Message sent when a team is selected."""

        def __init__(self, team_abbrev: str, team_name: str) -> None:
            self.team_abbrev = team_abbrev
            self.team_name = team_name
            super().__init__()

    def __init__(self, team_abbrev: str, team_name: str, **kwargs) -> None:
        super().__init__(**kwargs)
        self.team_abbrev = team_abbrev
        self.team_name = team_name

    def compose(self) -> ComposeResult:
        yield Label(self.team_abbrev, classes="team-abbrev")

    def on_click(self) -> None:
        self.post_message(self.Selected(self.team_abbrev, self.team_name))

    def on_key(self, event) -> None:
        if event.key == "enter":
            self.post_message(self.Selected(self.team_abbrev, self.team_name))
            event.stop()

Selected

Bases: Message

Message sent when a team is selected.

Source code in src/faceoff/screens/teams.py
49
50
51
52
53
54
55
class Selected(Message):
    """Message sent when a team is selected."""

    def __init__(self, team_abbrev: str, team_name: str) -> None:
        self.team_abbrev = team_abbrev
        self.team_name = team_name
        super().__init__()

TeamDetailScreen

Bases: Screen

Screen for viewing team details.

Source code in src/faceoff/screens/teams.py
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
class TeamDetailScreen(Screen):
    """Screen for viewing team details."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
        Binding("up,k", "focus_prev_player", "Previous", show=False),
        Binding("down,j", "focus_next_player", "Next", show=False),
    ]

    DEFAULT_CSS = """
    TeamDetailScreen {
        background: $surface;
    }

    TeamDetailScreen .team-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    TeamDetailScreen .detail-tabs {
        width: 100%;
        height: 1fr;
    }

    TeamDetailScreen .detail-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    TeamDetailScreen .section-header {
        width: 100%;
        height: 1;
        text-style: bold;
        background: $surface-lighten-1;
        padding: 0 1;
        margin-bottom: 1;
    }

    TeamDetailScreen .game-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    TeamDetailScreen .game-date {
        width: 12;
    }

    TeamDetailScreen .game-opponent {
        width: 1fr;
    }

    TeamDetailScreen .game-result {
        width: 10;
        text-align: right;
    }

    TeamDetailScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }

    TeamDetailScreen .position-section {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }
    """

    def __init__(self, client: NHLClient, team_abbrev: str, team_name: str, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.team_abbrev = team_abbrev
        self.team_name = team_name
        self.roster: dict = {}
        self.schedule: dict = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(f"{self.team_name} ({self.team_abbrev})", classes="team-header")
        with TabbedContent(classes="detail-tabs"):
            with TabPane("Roster", id="tab-roster"), VerticalScroll(id="roster-container", classes="detail-container"):
                yield Label("Loading...", classes="loading")
            with (
                TabPane("Schedule", id="tab-schedule"),
                VerticalScroll(id="schedule-container", classes="detail-container"),
            ):
                yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load team data when screen is mounted."""
        self.load_team_data()

    def load_team_data(self) -> None:
        """Load team data from API."""
        self.run_worker(self._fetch_team_data())

    async def _fetch_team_data(self) -> None:
        """Fetch team data from the API."""
        try:
            self.roster = await self.client.get_team_roster(self.team_abbrev)
            self.schedule = await self.client.get_team_month_schedule(self.team_abbrev)
            self._update_roster_view()
            self._update_schedule_view()
        except Exception as e:
            self.notify(f"Error loading team data: {e}", severity="error")

    def _update_roster_view(self) -> None:
        """Update the roster view."""
        container = self.query_one("#roster-container", VerticalScroll)
        container.remove_children()

        if not self.roster:
            container.mount(Label("No roster data available"))
            return

        positions = [
            ("forwards", "Forwards"),
            ("defensemen", "Defensemen"),
            ("goalies", "Goalies"),
        ]

        for key, label in positions:
            players = self.roster.get(key, [])
            if players:
                section = Vertical(classes="position-section")
                section.compose_add_child(Static(label, classes="section-header"))

                for player in sorted(players, key=lambda p: p.get("sweaterNumber", 99)):
                    section.compose_add_child(PlayerRow(player))

                container.mount(section)

    def _update_schedule_view(self) -> None:
        """Update the schedule view."""
        container = self.query_one("#schedule-container", VerticalScroll)
        container.remove_children()

        games = self.schedule.get("games", [])
        if not games:
            container.mount(Label("No scheduled games"))
            return

        # Separate completed and upcoming games
        completed_games = []
        upcoming_games = []
        for game in games:
            state = game.get("gameState", "FUT")
            if state in ("FINAL", "OFF"):
                completed_games.append(game)
            else:
                upcoming_games.append(game)

        # Take last 3 completed games + all upcoming
        recent_completed = completed_games[-3:] if completed_games else []
        display_games = recent_completed + upcoming_games

        if not display_games:
            container.mount(Label("No scheduled games"))
            return

        for game in display_games:
            row = Horizontal(classes="game-row")

            # Date
            start_time = game.get("startTimeUTC", "")
            local_time = get_local_time_with_tz(start_time)
            game_date = game.get("gameDate", "")

            # Opponent
            home = game.get("homeTeam", {}).get("abbrev", "???")
            away = game.get("awayTeam", {}).get("abbrev", "???")
            opponent = f"vs {away}" if home == self.team_abbrev else f"@ {home}"

            # Result/Time
            state = game.get("gameState", "FUT")
            if state in ("FINAL", "OFF"):
                home_score = game.get("homeTeam", {}).get("score", 0)
                away_score = game.get("awayTeam", {}).get("score", 0)
                if home == self.team_abbrev:
                    result = f"{'W' if home_score > away_score else 'L'} {home_score}-{away_score}"
                else:
                    result = f"{'W' if away_score > home_score else 'L'} {away_score}-{home_score}"
            elif state in ("LIVE", "CRIT"):
                result = "LIVE"
            else:
                result = local_time or "TBD"

            row.compose_add_child(Label(game_date, classes="game-date"))
            row.compose_add_child(Label(opponent, classes="game-opponent"))
            row.compose_add_child(Label(result, classes="game-result"))

            container.mount(row)

    def on_player_row_selected(self, event: PlayerRow.Selected) -> None:
        """Handle player selection."""
        from faceoff.screens.player import PlayerScreen

        self.app.push_screen(PlayerScreen(self.client, event.player_id, event.player_name))

    def action_back(self) -> None:
        """Go back."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh team data."""
        self.client.clear_cache()
        self.load_team_data()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

    def _get_focused_player_index(self) -> int:
        """Get the index of the currently focused player row, or -1 if none."""
        rows = list(self.query(PlayerRow))
        for i, row in enumerate(rows):
            if row.has_focus:
                return i
        return -1

    def _focus_player_at_index(self, index: int) -> None:
        """Focus the player row at the given index and scroll into view."""
        rows = list(self.query(PlayerRow))
        if rows and 0 <= index < len(rows):
            rows[index].focus()
            rows[index].scroll_visible()

    def action_focus_prev_player(self) -> None:
        """Focus the previous player row."""
        idx = self._get_focused_player_index()
        if idx > 0:
            self._focus_player_at_index(idx - 1)
        elif idx == -1:
            # No player focused, focus the first one
            self._focus_player_at_index(0)

    def action_focus_next_player(self) -> None:
        """Focus the next player row."""
        idx = self._get_focused_player_index()
        rows = list(self.query(PlayerRow))
        if idx < len(rows) - 1:
            self._focus_player_at_index(idx + 1)
        elif idx == -1 and rows:
            # No player focused, focus the first one
            self._focus_player_at_index(0)

action_back()

Go back.

Source code in src/faceoff/screens/teams.py
582
583
584
def action_back(self) -> None:
    """Go back."""
    self.app.pop_screen()

action_focus_next_player()

Focus the next player row.

Source code in src/faceoff/screens/teams.py
620
621
622
623
624
625
626
627
628
def action_focus_next_player(self) -> None:
    """Focus the next player row."""
    idx = self._get_focused_player_index()
    rows = list(self.query(PlayerRow))
    if idx < len(rows) - 1:
        self._focus_player_at_index(idx + 1)
    elif idx == -1 and rows:
        # No player focused, focus the first one
        self._focus_player_at_index(0)

action_focus_prev_player()

Focus the previous player row.

Source code in src/faceoff/screens/teams.py
611
612
613
614
615
616
617
618
def action_focus_prev_player(self) -> None:
    """Focus the previous player row."""
    idx = self._get_focused_player_index()
    if idx > 0:
        self._focus_player_at_index(idx - 1)
    elif idx == -1:
        # No player focused, focus the first one
        self._focus_player_at_index(0)

action_quit()

Quit the application.

Source code in src/faceoff/screens/teams.py
592
593
594
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh team data.

Source code in src/faceoff/screens/teams.py
586
587
588
589
590
def action_refresh(self) -> None:
    """Manually refresh team data."""
    self.client.clear_cache()
    self.load_team_data()
    self.notify("Refreshed")

load_team_data()

Load team data from API.

Source code in src/faceoff/screens/teams.py
475
476
477
def load_team_data(self) -> None:
    """Load team data from API."""
    self.run_worker(self._fetch_team_data())

on_mount()

Load team data when screen is mounted.

Source code in src/faceoff/screens/teams.py
471
472
473
def on_mount(self) -> None:
    """Load team data when screen is mounted."""
    self.load_team_data()

on_player_row_selected(event)

Handle player selection.

Source code in src/faceoff/screens/teams.py
576
577
578
579
580
def on_player_row_selected(self, event: PlayerRow.Selected) -> None:
    """Handle player selection."""
    from faceoff.screens.player import PlayerScreen

    self.app.push_screen(PlayerScreen(self.client, event.player_id, event.player_name))

TeamsScreen

Bases: Screen

Screen for browsing NHL teams.

Source code in src/faceoff/screens/teams.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class TeamsScreen(Screen):
    """Screen for browsing NHL teams."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
        Binding("left", "focus_prev_card", "Previous", show=False),
        Binding("right", "focus_next_card", "Next", show=False),
        Binding("up", "focus_card_above", "Up", show=False),
        Binding("down", "focus_card_below", "Down", show=False),
    ]

    DEFAULT_CSS = """
    TeamsScreen {
        background: $surface;
    }

    TeamsScreen .teams-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    TeamsScreen .teams-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    TeamsScreen .teams-grid {
        width: 100%;
        height: auto;
    }

    TeamsScreen .teams-row {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }

    TeamsScreen .conference-label {
        width: 100%;
        text-style: bold;
        background: $primary;
        padding: 0 1;
        margin-bottom: 1;
    }

    TeamsScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }
    """

    def __init__(self, client: NHLClient, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.teams: list = []
        self._last_width: int = 0

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("NHL Teams", classes="teams-header")
        with (
            VerticalScroll(classes="teams-container", id="teams-container"),
            Vertical(classes="teams-grid", id="teams-grid"),
        ):
            yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load teams when screen is mounted."""
        self.load_teams()

    def load_teams(self) -> None:
        """Load teams from API."""
        self.run_worker(self._fetch_teams())

    async def _fetch_teams(self) -> None:
        """Fetch teams from the API (via standings)."""
        try:
            data = await self.client.get_standings()
            self.teams = data.get("standings", [])
            self._update_teams_display()
        except Exception as e:
            self.notify(f"Error loading teams: {e}", severity="error")

    def _get_cards_per_row(self) -> int:
        """Calculate how many team cards fit per row based on container width."""
        try:
            scroll = self.query_one("#teams-container", VerticalScroll)
            available_width = scroll.size.width - 4  # Account for padding
            cards_per_row = max(1, available_width // TEAM_CARD_WIDTH)
        except Exception:
            return 6  # Default fallback
        else:
            return cards_per_row

    def _update_teams_display(self) -> None:
        """Update the teams grid."""
        grid = self.query_one("#teams-grid", Vertical)
        grid.remove_children()

        if not self.teams:
            grid.mount(Label("No teams data available"))
            return

        cards_per_row = self._get_cards_per_row()

        # Group by conference
        conferences: dict[str, list] = {}
        for team in self.teams:
            conf = team.get("conferenceName", "Unknown")
            if conf not in conferences:
                conferences[conf] = []
            conferences[conf].append(team)

        for conf_name in sorted(conferences.keys()):
            grid.mount(Static(f"{conf_name} Conference", classes="conference-label"))

            teams = sorted(conferences[conf_name], key=lambda t: t.get("teamAbbrev", {}).get("default", ""))

            # Create multiple rows based on cards_per_row
            for i in range(0, len(teams), cards_per_row):
                row_teams = teams[i : i + cards_per_row]
                row = Horizontal(classes="teams-row")

                for team in row_teams:
                    abbrev = team.get("teamAbbrev", {}).get("default", "???")
                    name = team.get("teamName", {}).get("default", abbrev)
                    card = TeamCard(abbrev, name)
                    row.compose_add_child(card)

                grid.mount(row)

    def on_resize(self, event) -> None:
        """Handle terminal resize to reflow team cards."""
        if not self.teams:
            return
        try:
            scroll = self.query_one("#teams-container", VerticalScroll)
            new_width = scroll.size.width
        except Exception:
            return
        if abs(new_width - self._last_width) >= TEAM_CARD_WIDTH:
            self._last_width = new_width
            self._update_teams_display()

    def on_team_card_selected(self, event: TeamCard.Selected) -> None:
        """Handle team selection."""
        self.app.push_screen(TeamDetailScreen(self.client, event.team_abbrev, event.team_name))

    def action_back(self) -> None:
        """Go back to schedule."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh teams."""
        self.client.clear_cache()
        self.load_teams()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

    def _get_focused_card_index(self) -> int:
        """Get the index of the currently focused card, or -1 if none."""
        cards = list(self.query(TeamCard))
        for i, card in enumerate(cards):
            if card.has_focus:
                return i
        return -1

    def _focus_card_at_index(self, index: int) -> None:
        """Focus the card at the given index."""
        cards = list(self.query(TeamCard))
        if cards and 0 <= index < len(cards):
            cards[index].focus()

    def action_focus_prev_card(self) -> None:
        """Focus the previous team card."""
        idx = self._get_focused_card_index()
        if idx > 0:
            self._focus_card_at_index(idx - 1)
        elif idx == -1:
            # No card focused, focus the first one
            self._focus_card_at_index(0)

    def action_focus_next_card(self) -> None:
        """Focus the next team card."""
        idx = self._get_focused_card_index()
        cards = list(self.query(TeamCard))
        if idx < len(cards) - 1:
            self._focus_card_at_index(idx + 1)
        elif idx == -1 and cards:
            # No card focused, focus the first one
            self._focus_card_at_index(0)

    def action_focus_card_above(self) -> None:
        """Focus the team card above (previous row, same column)."""
        idx = self._get_focused_card_index()
        if idx < 0:
            self._focus_card_at_index(0)
            return
        cards_per_row = self._get_cards_per_row()
        new_idx = idx - cards_per_row
        if new_idx >= 0:
            self._focus_card_at_index(new_idx)

    def action_focus_card_below(self) -> None:
        """Focus the team card below (next row, same column)."""
        idx = self._get_focused_card_index()
        if idx < 0:
            self._focus_card_at_index(0)
            return
        cards = list(self.query(TeamCard))
        cards_per_row = self._get_cards_per_row()
        new_idx = idx + cards_per_row
        if new_idx < len(cards):
            self._focus_card_at_index(new_idx)

action_back()

Go back to schedule.

Source code in src/faceoff/screens/teams.py
303
304
305
def action_back(self) -> None:
    """Go back to schedule."""
    self.app.pop_screen()

action_focus_card_above()

Focus the team card above (previous row, same column).

Source code in src/faceoff/screens/teams.py
350
351
352
353
354
355
356
357
358
359
def action_focus_card_above(self) -> None:
    """Focus the team card above (previous row, same column)."""
    idx = self._get_focused_card_index()
    if idx < 0:
        self._focus_card_at_index(0)
        return
    cards_per_row = self._get_cards_per_row()
    new_idx = idx - cards_per_row
    if new_idx >= 0:
        self._focus_card_at_index(new_idx)

action_focus_card_below()

Focus the team card below (next row, same column).

Source code in src/faceoff/screens/teams.py
361
362
363
364
365
366
367
368
369
370
371
def action_focus_card_below(self) -> None:
    """Focus the team card below (next row, same column)."""
    idx = self._get_focused_card_index()
    if idx < 0:
        self._focus_card_at_index(0)
        return
    cards = list(self.query(TeamCard))
    cards_per_row = self._get_cards_per_row()
    new_idx = idx + cards_per_row
    if new_idx < len(cards):
        self._focus_card_at_index(new_idx)

action_focus_next_card()

Focus the next team card.

Source code in src/faceoff/screens/teams.py
340
341
342
343
344
345
346
347
348
def action_focus_next_card(self) -> None:
    """Focus the next team card."""
    idx = self._get_focused_card_index()
    cards = list(self.query(TeamCard))
    if idx < len(cards) - 1:
        self._focus_card_at_index(idx + 1)
    elif idx == -1 and cards:
        # No card focused, focus the first one
        self._focus_card_at_index(0)

action_focus_prev_card()

Focus the previous team card.

Source code in src/faceoff/screens/teams.py
331
332
333
334
335
336
337
338
def action_focus_prev_card(self) -> None:
    """Focus the previous team card."""
    idx = self._get_focused_card_index()
    if idx > 0:
        self._focus_card_at_index(idx - 1)
    elif idx == -1:
        # No card focused, focus the first one
        self._focus_card_at_index(0)

action_quit()

Quit the application.

Source code in src/faceoff/screens/teams.py
313
314
315
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh teams.

Source code in src/faceoff/screens/teams.py
307
308
309
310
311
def action_refresh(self) -> None:
    """Manually refresh teams."""
    self.client.clear_cache()
    self.load_teams()
    self.notify("Refreshed")

load_teams()

Load teams from API.

Source code in src/faceoff/screens/teams.py
225
226
227
def load_teams(self) -> None:
    """Load teams from API."""
    self.run_worker(self._fetch_teams())

on_mount()

Load teams when screen is mounted.

Source code in src/faceoff/screens/teams.py
221
222
223
def on_mount(self) -> None:
    """Load teams when screen is mounted."""
    self.load_teams()

on_resize(event)

Handle terminal resize to reflow team cards.

Source code in src/faceoff/screens/teams.py
286
287
288
289
290
291
292
293
294
295
296
297
def on_resize(self, event) -> None:
    """Handle terminal resize to reflow team cards."""
    if not self.teams:
        return
    try:
        scroll = self.query_one("#teams-container", VerticalScroll)
        new_width = scroll.size.width
    except Exception:
        return
    if abs(new_width - self._last_width) >= TEAM_CARD_WIDTH:
        self._last_width = new_width
        self._update_teams_display()

on_team_card_selected(event)

Handle team selection.

Source code in src/faceoff/screens/teams.py
299
300
301
def on_team_card_selected(self, event: TeamCard.Selected) -> None:
    """Handle team selection."""
    self.app.push_screen(TeamDetailScreen(self.client, event.team_abbrev, event.team_name))

Player screen for viewing player details and stats.

PlayerScreen

Bases: Screen

Screen for viewing player details.

Source code in src/faceoff/screens/player.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
class PlayerScreen(Screen):
    """Screen for viewing player details."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("escape,b", "back", "Back"),
        Binding("r", "refresh", "Refresh"),
        Binding("q", "quit", "Quit"),
    ]

    DEFAULT_CSS = """
    PlayerScreen {
        background: $surface;
    }

    PlayerScreen .player-header {
        width: 100%;
        height: 3;
        align: center middle;
        text-style: bold;
        border-bottom: solid $primary;
    }

    PlayerScreen .player-container {
        width: 100%;
        height: 1fr;
        padding: 1;
    }

    PlayerScreen .info-section {
        width: 100%;
        height: auto;
        border: solid $primary;
        padding: 1;
        margin-bottom: 1;
    }

    PlayerScreen .info-row {
        width: 100%;
        height: 1;
    }

    PlayerScreen .info-label {
        width: 16;
        text-style: bold;
    }

    PlayerScreen .info-value {
        width: 1fr;
    }

    PlayerScreen .stats-section {
        width: 100%;
        height: auto;
        margin-bottom: 1;
    }

    PlayerScreen .section-header {
        width: 100%;
        height: 1;
        text-style: bold;
        background: $primary;
        padding: 0 1;
        margin-bottom: 1;
    }

    PlayerScreen .stats-header {
        width: 100%;
        height: 1;
        padding: 0 1;
        text-style: bold;
        color: $text-muted;
    }

    PlayerScreen .stats-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    PlayerScreen .stat-cell {
        width: 8;
        text-align: center;
    }

    PlayerScreen .stat-cell-wide {
        width: 12;
        text-align: center;
    }

    PlayerScreen .loading {
        width: 100%;
        height: 100%;
        align: center middle;
    }

    PlayerScreen .game-log-row {
        width: 100%;
        height: 1;
        padding: 0 1;
    }

    PlayerScreen .game-log-row:hover {
        background: $surface-lighten-2;
    }
    """

    def __init__(self, client: NHLClient, player_id: int, player_name: str, **kwargs) -> None:
        super().__init__(**kwargs)
        self.client = client
        self.player_id = player_id
        self.player_name = player_name
        self.player_data: dict = {}
        self.game_log: dict = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(self.player_name, classes="player-header", id="player-header")
        with VerticalScroll(id="player-container", classes="player-container"):
            yield Label("Loading...", classes="loading")
        yield Footer()

    def on_mount(self) -> None:
        """Load player data when screen is mounted."""
        self.load_player_data()

    def load_player_data(self) -> None:
        """Load player data from API."""
        self.run_worker(self._fetch_player_data())

    async def _fetch_player_data(self) -> None:
        """Fetch player data from the API."""
        try:
            self.player_data = await self.client.get_player_landing(self.player_id)
            self.game_log = await self.client.get_player_game_log(self.player_id)

            # Update header with full name
            first = self.player_data.get("firstName", {}).get("default", "")
            last = self.player_data.get("lastName", {}).get("default", "")
            if first and last:
                header = self.query_one("#player-header", Static)
                header.update(f"{first} {last}")

            self._update_player_view()
        except Exception as e:
            self.notify(f"Error loading player data: {e}", severity="error")

    def _update_player_view(self) -> None:
        """Update the combined player view with info, stats, and game log."""
        container = self.query_one("#player-container", VerticalScroll)
        container.remove_children()

        if not self.player_data:
            container.mount(Label("No player data available"))
            return

        position = self.player_data.get("position", "")
        is_goalie = position == "G"

        # Mount all sections
        container.mount(self._build_info_section())

        stats_section = self._build_stats_section(is_goalie)
        if stats_section:
            container.mount(stats_section)

        gamelog_section = self._build_gamelog_section(is_goalie)
        if gamelog_section:
            container.mount(gamelog_section)

    def _build_info_section(self) -> Vertical:
        """Build the player info section."""
        info_section = Vertical(classes="info-section")

        info_items = [
            ("Team", self.player_data.get("fullTeamName", {}).get("default", "N/A")),
            ("Position", self.player_data.get("position", "N/A")),
            ("Number", f"#{self.player_data.get('sweaterNumber', 'N/A')}"),
            ("Height", self.player_data.get("heightInCentimeters", "N/A")),
            ("Weight", f"{self.player_data.get('weightInPounds', 'N/A')} lbs"),
            ("Birth Date", self.player_data.get("birthDate", "N/A")),
            ("Birth City", self.player_data.get("birthCity", {}).get("default", "N/A")),
            ("Birth Country", self.player_data.get("birthCountry", "N/A")),
            ("Shoots/Catches", self.player_data.get("shootsCatches", "N/A")),
        ]

        for label, value in info_items:
            row = Horizontal(classes="info-row")
            row.compose_add_child(Label(label, classes="info-label"))
            row.compose_add_child(Label(str(value), classes="info-value"))
            info_section.compose_add_child(row)

        return info_section

    def _build_stats_section(self, is_goalie: bool) -> Vertical | None:
        """Build the stats section."""
        featured_stats = self.player_data.get("featuredStats", {})
        season_stats = featured_stats.get("regularSeason", {}).get("subSeason", {})
        career_stats = featured_stats.get("regularSeason", {}).get("career", {})

        if not season_stats and not career_stats:
            return None

        stats_section = Vertical(classes="stats-section")
        stats_section.compose_add_child(Static("Season & Career Stats", classes="section-header"))

        if is_goalie:
            self._add_goalie_stats(stats_section, season_stats, career_stats)
        else:
            self._add_skater_stats(stats_section, season_stats, career_stats)

        return stats_section

    def _add_goalie_stats(self, section: Vertical, season: dict, career: dict) -> None:
        """Add goalie stats to a section."""
        header = Horizontal(classes="stats-header")
        for col in ["", "GP", "W", "L", "OTL", "GAA", "SV%", "SO"]:
            header.compose_add_child(Label(col, classes="stat-cell-wide" if not col else "stat-cell"))
        section.compose_add_child(header)

        for label, stats in [("This Season", season), ("Career", career)]:
            if stats:
                gaa = stats.get("goalsAgainstAvg", 0)
                sv_pct = stats.get("savePctg", 0)
                row = Horizontal(classes="stats-row")
                row.compose_add_child(Label(label, classes="stat-cell-wide"))
                row.compose_add_child(Label(str(stats.get("gamesPlayed", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("wins", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("losses", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("otLosses", 0)), classes="stat-cell"))
                row.compose_add_child(Label(f"{gaa:.2f}" if gaa else "0.00", classes="stat-cell"))
                row.compose_add_child(Label(f"{sv_pct:.3f}" if sv_pct else ".000", classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("shutouts", 0)), classes="stat-cell"))
                section.compose_add_child(row)

    def _add_skater_stats(self, section: Vertical, season: dict, career: dict) -> None:
        """Add skater stats to a section."""
        header = Horizontal(classes="stats-header")
        for col in ["", "GP", "G", "A", "PTS", "+/-", "PIM", "PPG", "SHG"]:
            header.compose_add_child(Label(col, classes="stat-cell-wide" if not col else "stat-cell"))
        section.compose_add_child(header)

        for label, stats in [("This Season", season), ("Career", career)]:
            if stats:
                row = Horizontal(classes="stats-row")
                row.compose_add_child(Label(label, classes="stat-cell-wide"))
                row.compose_add_child(Label(str(stats.get("gamesPlayed", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("goals", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("assists", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("points", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("plusMinus", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("pim", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("powerPlayGoals", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(stats.get("shorthandedGoals", 0)), classes="stat-cell"))
                section.compose_add_child(row)

    def _build_gamelog_section(self, is_goalie: bool) -> Vertical | None:
        """Build the game log section."""
        game_log = self.game_log.get("gameLog", [])
        if not game_log:
            return None

        gamelog_section = Vertical(classes="stats-section")
        gamelog_section.compose_add_child(Static("Recent Games", classes="section-header"))

        if is_goalie:
            cols = ["Date", "Opp", "Dec", "GA", "SA", "SV%", "TOI"]
        else:
            cols = ["Date", "Opp", "G", "A", "PTS", "+/-", "SOG", "TOI"]

        header = Horizontal(classes="stats-header")
        for col in cols:
            header.compose_add_child(Label(col, classes="stat-cell-wide" if col == "Date" else "stat-cell"))
        gamelog_section.compose_add_child(header)

        for game in game_log[:10]:
            row = Horizontal(classes="game-log-row")
            row.compose_add_child(Label(game.get("gameDate", ""), classes="stat-cell-wide"))
            row.compose_add_child(Label(game.get("opponentAbbrev", ""), classes="stat-cell"))

            if is_goalie:
                row.compose_add_child(Label(game.get("decision", "-"), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("goalsAgainst", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("shotsAgainst", 0)), classes="stat-cell"))
                sv_pct = game.get("savePctg", 0)
                row.compose_add_child(Label(f"{sv_pct:.3f}" if sv_pct else ".000", classes="stat-cell"))
            else:
                row.compose_add_child(Label(str(game.get("goals", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("assists", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("points", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("plusMinus", 0)), classes="stat-cell"))
                row.compose_add_child(Label(str(game.get("shots", 0)), classes="stat-cell"))

            row.compose_add_child(Label(game.get("toi", "0:00"), classes="stat-cell"))
            gamelog_section.compose_add_child(row)

        return gamelog_section

    def action_back(self) -> None:
        """Go back."""
        self.app.pop_screen()

    def action_refresh(self) -> None:
        """Manually refresh player data."""
        self.client.clear_cache()
        self.load_player_data()
        self.notify("Refreshed")

    def action_quit(self) -> None:
        """Quit the application."""
        self.app.exit()

action_back()

Go back.

Source code in src/faceoff/screens/player.py
311
312
313
def action_back(self) -> None:
    """Go back."""
    self.app.pop_screen()

action_quit()

Quit the application.

Source code in src/faceoff/screens/player.py
321
322
323
def action_quit(self) -> None:
    """Quit the application."""
    self.app.exit()

action_refresh()

Manually refresh player data.

Source code in src/faceoff/screens/player.py
315
316
317
318
319
def action_refresh(self) -> None:
    """Manually refresh player data."""
    self.client.clear_cache()
    self.load_player_data()
    self.notify("Refreshed")

load_player_data()

Load player data from API.

Source code in src/faceoff/screens/player.py
139
140
141
def load_player_data(self) -> None:
    """Load player data from API."""
    self.run_worker(self._fetch_player_data())

on_mount()

Load player data when screen is mounted.

Source code in src/faceoff/screens/player.py
135
136
137
def on_mount(self) -> None:
    """Load player data when screen is mounted."""
    self.load_player_data()

Widgets

Game card widget for displaying a single game in the schedule.

GameCard

Bases: Widget

A card widget displaying a single game's status.

Source code in src/faceoff/widgets/game_card.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
class GameCard(Widget):
    """A card widget displaying a single game's status."""

    DEFAULT_CSS = """
    GameCard {
        width: 28;
        height: 5;
        border: solid $primary;
        padding: 0 1;
        margin: 0 1 0 0;
    }

    GameCard:hover {
        border: solid $secondary;
    }

    GameCard:focus {
        border: double $accent;
    }

    GameCard.-live {
        border: solid $success;
    }

    GameCard.-live:focus {
        border: double $accent;
    }

    GameCard.-final {
        border: solid $surface;
    }

    GameCard.-final:focus {
        border: double $accent;
    }

    GameCard .team-row {
        width: 100%;
        height: 1;
    }

    GameCard .team-name {
        width: 1fr;
    }

    GameCard .team-score {
        width: 3;
        text-align: right;
    }

    GameCard .game-status {
        width: 100%;
        height: 1;
        text-align: center;
        color: $text-muted;
    }

    GameCard.-live .game-status {
        color: $success;
    }
    """

    can_focus = True

    class Selected(Message):
        """Message sent when a game card is selected."""

        def __init__(self, game_id: int, game_data: dict) -> None:
            self.game_id = game_id
            self.game_data = game_data
            super().__init__()

    def __init__(self, game_data: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.game_data = game_data
        self.game_id = game_data.get("id", 0)

    def compose(self) -> ComposeResult:
        away = self.game_data.get("awayTeam", {})
        home = self.game_data.get("homeTeam", {})
        game_state = self.game_data.get("gameState", "FUT")

        away_name = away.get("abbrev", "???")
        home_name = home.get("abbrev", "???")

        away_score = away.get("score", "-")
        home_score = home.get("score", "-")

        # Determine game status text
        status = self._get_status_text()

        with Vertical():
            with Horizontal(classes="team-row"):
                yield Label(f"{away_name}", classes="team-name")
                yield Label(f"{away_score}" if game_state not in ("FUT", "PRE") else "", classes="team-score")
            with Horizontal(classes="team-row"):
                yield Label(f"{home_name}", classes="team-name")
                yield Label(f"{home_score}" if game_state not in ("FUT", "PRE") else "", classes="team-score")
            yield Static(status, classes="game-status")

    def _get_status_text(self) -> str:  # noqa: C901
        """Get the status text for the game."""
        game_state = self.game_data.get("gameState", "FUT")
        game_schedule_state = self.game_data.get("gameScheduleState", "OK")

        if game_schedule_state == "PPD":
            return "Postponed"
        if game_schedule_state == "CNCL":
            return "Cancelled"

        if game_state == "FUT":
            start_time = self.game_data.get("startTimeUTC", "")
            local_time = get_local_time_with_tz(start_time)
            return local_time if local_time else "Scheduled"

        if game_state == "PRE":
            return "Pre-game"

        if game_state in ("LIVE", "CRIT"):
            period = self.game_data.get("periodDescriptor", {})
            period_num = period.get("number", 0)
            period_type = period.get("periodType", "REG")

            if period_type == "OT":
                period_str = "OT"
            elif period_type == "SO":
                period_str = "SO"
            else:
                ordinals = {1: "1st", 2: "2nd", 3: "3rd"}
                period_str = ordinals.get(period_num, f"{period_num}th")

            clock = self.game_data.get("clock", {})
            time_remaining = clock.get("timeRemaining", "")
            in_intermission = clock.get("inIntermission", False)

            if in_intermission:
                return f"{period_str} INT"
            return f"{period_str} {time_remaining}"

        if game_state in ("FINAL", "OFF"):
            period = self.game_data.get("periodDescriptor", {})
            period_type = period.get("periodType", "REG")
            if period_type == "OT":
                return "Final/OT"
            if period_type == "SO":
                return "Final/SO"
            return "Final"

        return game_state

    def on_mount(self) -> None:
        """Apply CSS classes based on game state."""
        game_state = self.game_data.get("gameState", "FUT")
        if game_state in ("LIVE", "CRIT"):
            self.add_class("-live")
        elif game_state in ("FINAL", "OFF"):
            self.add_class("-final")

    def on_click(self) -> None:
        """Handle click event."""
        self.post_message(self.Selected(self.game_id, self.game_data))

    def on_key(self, event) -> None:
        """Handle key events."""
        if event.key == "enter":
            self.post_message(self.Selected(self.game_id, self.game_data))
            event.stop()

Selected

Bases: Message

Message sent when a game card is selected.

Source code in src/faceoff/widgets/game_card.py
104
105
106
107
108
109
110
class Selected(Message):
    """Message sent when a game card is selected."""

    def __init__(self, game_id: int, game_data: dict) -> None:
        self.game_id = game_id
        self.game_data = game_data
        super().__init__()

on_click()

Handle click event.

Source code in src/faceoff/widgets/game_card.py
198
199
200
def on_click(self) -> None:
    """Handle click event."""
    self.post_message(self.Selected(self.game_id, self.game_data))

on_key(event)

Handle key events.

Source code in src/faceoff/widgets/game_card.py
202
203
204
205
206
def on_key(self, event) -> None:
    """Handle key events."""
    if event.key == "enter":
        self.post_message(self.Selected(self.game_id, self.game_data))
        event.stop()

on_mount()

Apply CSS classes based on game state.

Source code in src/faceoff/widgets/game_card.py
190
191
192
193
194
195
196
def on_mount(self) -> None:
    """Apply CSS classes based on game state."""
    game_state = self.game_data.get("gameState", "FUT")
    if game_state in ("LIVE", "CRIT"):
        self.add_class("-live")
    elif game_state in ("FINAL", "OFF"):
        self.add_class("-final")

get_local_time_with_tz(utc_time_str)

Convert UTC time string to local time with timezone abbreviation.

Source code in src/faceoff/widgets/game_card.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def get_local_time_with_tz(utc_time_str: str) -> str:
    """Convert UTC time string to local time with timezone abbreviation."""
    if not utc_time_str:
        return ""
    try:
        # Parse UTC time
        dt_utc = datetime.fromisoformat(utc_time_str.replace("Z", "+00:00"))
        # Convert to local time
        dt_local = dt_utc.astimezone()
        # Format time with timezone
        time_str = dt_local.strftime("%I:%M %p")
        # Get timezone abbreviation
        tz_abbrev = dt_local.strftime("%Z")
        # If no abbreviation available, show offset
        if not tz_abbrev or tz_abbrev == dt_local.strftime("%z"):
            offset = dt_local.strftime("%z")
            if offset and len(offset) >= 5:
                tz_abbrev = f"UTC{offset[:3]}:{offset[3:]}"
            elif offset:
                tz_abbrev = f"UTC{offset}"
            else:
                tz_abbrev = "UTC"
    except (ValueError, AttributeError, IndexError):
        return ""
    else:
        return f"{time_str} {tz_abbrev}"

Scoreboard widget for displaying game score and status.

Scoreboard

Bases: Widget

Widget displaying the current game score and status.

Source code in src/faceoff/widgets/scoreboard.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class Scoreboard(Widget):
    """Widget displaying the current game score and status."""

    DEFAULT_CSS = """
    Scoreboard {
        width: 100%;
        height: auto;
        border: solid $primary;
        padding: 0 1;
    }

    Scoreboard .header {
        width: 100%;
        height: 1;
        text-align: center;
        text-style: bold;
    }

    Scoreboard .teams-container {
        width: 100%;
        height: auto;
        align: center middle;
    }

    Scoreboard .team-block {
        width: auto;
        min-width: 16;
        height: auto;
        align: center middle;
        padding: 0 1;
    }

    Scoreboard .team-name {
        text-align: center;
        text-style: bold;
        width: 100%;
    }

    Scoreboard .team-score {
        text-align: center;
        width: 100%;
        text-style: bold;
    }

    Scoreboard .team-score.-winning {
        color: $success;
    }

    Scoreboard .vs-label {
        width: auto;
        padding: 0 1;
    }

    Scoreboard .status-line {
        width: 100%;
        height: 1;
        text-align: center;
    }

    Scoreboard.-live .status-line {
        color: $success;
    }

    Scoreboard .period-scores {
        width: 100%;
        height: auto;
        align: center middle;
    }

    Scoreboard .period-header {
        width: 6;
        text-align: center;
        text-style: bold;
    }

    Scoreboard .period-score {
        width: 6;
        text-align: center;
    }
    """

    def __init__(self, game_data: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.game_data = game_data

    def compose(self) -> ComposeResult:
        away = self.game_data.get("awayTeam", {})
        home = self.game_data.get("homeTeam", {})
        game_state = self.game_data.get("gameState", "FUT")

        away_name = away.get("name", {}).get("default", away.get("abbrev", "Away"))
        home_name = home.get("name", {}).get("default", home.get("abbrev", "Home"))
        away_abbrev = away.get("abbrev", "AWY")
        home_abbrev = home.get("abbrev", "HOM")

        away_score = away.get("score", 0)
        home_score = home.get("score", 0)

        # Venue info
        venue = self.game_data.get("venue", {}).get("default", "")

        with Vertical():
            yield Static(venue, classes="header")

            with Horizontal(classes="teams-container"):
                with Vertical(classes="team-block"):
                    yield Label(away_name, classes="team-name")
                    away_class = "team-score -winning" if away_score > home_score else "team-score"
                    yield Label(str(away_score) if game_state not in ("FUT", "PRE") else "-", classes=away_class)

                yield Label("@", classes="vs-label")

                with Vertical(classes="team-block"):
                    yield Label(home_name, classes="team-name")
                    home_class = "team-score -winning" if home_score > away_score else "team-score"
                    yield Label(str(home_score) if game_state not in ("FUT", "PRE") else "-", classes=home_class)

            yield Static(self._get_status_text(), classes="status-line")

            # Period-by-period scoring
            if game_state not in ("FUT", "PRE") and "periodDescriptor" in self.game_data:
                yield self._compose_period_scores(away_abbrev, home_abbrev)

    def _compose_period_scores(self, away_abbrev: str, home_abbrev: str) -> Widget:
        """Compose the period-by-period scoring table (placeholder)."""
        # Note: Period-by-period data is not currently available in game_data
        # This could be extended to show linescore if passed from GameScreen
        return Static("")

    def _get_status_text(self) -> str:
        """Get the status text for the game."""
        game_state = self.game_data.get("gameState", "FUT")

        if game_state == "FUT":
            start_time = self.game_data.get("startTimeUTC", "")
            local_time = get_local_time_with_tz(start_time)
            return local_time if local_time else "Scheduled"

        if game_state == "PRE":
            return "Pre-game"

        if game_state in ("LIVE", "CRIT"):
            period = self.game_data.get("periodDescriptor", {})
            period_num = period.get("number", 0)
            period_type = period.get("periodType", "REG")

            if period_type == "OT":
                period_str = "OT"
            elif period_type == "SO":
                period_str = "SO"
            else:
                ordinals = {1: "1st", 2: "2nd", 3: "3rd"}
                period_str = ordinals.get(period_num, f"{period_num}th")

            clock = self.game_data.get("clock", {})
            time_remaining = clock.get("timeRemaining", "20:00")
            in_intermission = clock.get("inIntermission", False)

            if in_intermission:
                return f"{period_str} Intermission"
            return f"{period_str} Period - {time_remaining}"

        if game_state in ("FINAL", "OFF"):
            period = self.game_data.get("periodDescriptor", {})
            period_type = period.get("periodType", "REG")
            if period_type == "OT":
                return "Final (OT)"
            if period_type == "SO":
                return "Final (SO)"
            return "Final"

        return game_state

    def on_mount(self) -> None:
        """Apply CSS classes based on game state."""
        game_state = self.game_data.get("gameState", "FUT")
        if game_state in ("LIVE", "CRIT"):
            self.add_class("-live")

    def update_game(self, game_data: dict) -> None:
        """Update the game data and refresh the display."""
        self.game_data = game_data
        self.refresh(recompose=True)

on_mount()

Apply CSS classes based on game state.

Source code in src/faceoff/widgets/scoreboard.py
184
185
186
187
188
def on_mount(self) -> None:
    """Apply CSS classes based on game state."""
    game_state = self.game_data.get("gameState", "FUT")
    if game_state in ("LIVE", "CRIT"):
        self.add_class("-live")

update_game(game_data)

Update the game data and refresh the display.

Source code in src/faceoff/widgets/scoreboard.py
190
191
192
193
def update_game(self, game_data: dict) -> None:
    """Update the game data and refresh the display."""
    self.game_data = game_data
    self.refresh(recompose=True)

Play-by-play widget for displaying game events.

PlayByPlay

Bases: Widget

Widget displaying play-by-play events for a game.

Source code in src/faceoff/widgets/play_by_play.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
class PlayByPlay(Widget):
    """Widget displaying play-by-play events for a game."""

    DEFAULT_CSS = """
    PlayByPlay {
        width: 100%;
        height: 1fr;
        border: solid $primary;
    }

    PlayByPlay .header {
        width: 100%;
        height: 1;
        background: $primary;
        color: $text;
        text-align: center;
        text-style: bold;
        padding: 0 1;
    }

    PlayByPlay .plays-container {
        width: 100%;
        height: 1fr;
        padding: 0 1;
    }

    PlayByPlay .play-item {
        width: 100%;
        height: auto;
        margin: 0 0 1 0;
    }

    PlayByPlay .play-time {
        color: $text-muted;
        width: 8;
    }

    PlayByPlay .play-description {
        width: 1fr;
    }

    PlayByPlay .play-goal {
        color: $success;
        text-style: bold;
    }

    PlayByPlay .play-penalty {
        color: $warning;
    }

    PlayByPlay .play-period {
        width: 100%;
        height: 1;
        background: $surface;
        text-align: center;
        text-style: bold;
        margin: 1 0;
    }

    PlayByPlay .no-plays {
        width: 100%;
        height: 100%;
        align: center middle;
        color: $text-muted;
    }
    """

    def __init__(self, plays: list | None = None, **kwargs) -> None:
        super().__init__(**kwargs)
        self.plays = plays or []

    def compose(self) -> ComposeResult:
        yield Static("Play-by-Play", classes="header")

        with VerticalScroll(classes="plays-container"):
            if not self.plays:
                yield Label("No plays yet", classes="no-plays")
            else:
                current_period = None
                # Show plays in reverse order (newest first)
                for play in reversed(self.plays):
                    period_desc = play.get("periodDescriptor", {})
                    period_num = period_desc.get("number", 0)
                    period_type = period_desc.get("periodType", "REG")

                    # Period header
                    if period_type == "OT":
                        period_label = "Overtime"
                    elif period_type == "SO":
                        period_label = "Shootout"
                    else:
                        ordinals = {1: "1st", 2: "2nd", 3: "3rd"}
                        period_label = f"{ordinals.get(period_num, str(period_num))} Period"

                    if period_label != current_period:
                        current_period = period_label
                        yield Static(period_label, classes="play-period")

                    yield self._render_play(play)

    def _render_play(self, play: dict) -> Widget:  # noqa: C901
        """Render a single play event."""
        event_type = play.get("typeDescKey", "")
        time_in_period = play.get("timeInPeriod", "")
        desc = play.get("typeDescKey", "").replace("-", " ").title()

        # Get more detailed description based on event type
        details = play.get("details", {})

        # Determine CSS class based on event type
        css_class = "play-description"
        if event_type == "goal":
            css_class = "play-description play-goal"
            scorer = details.get("scoringPlayerTotal", details.get("scoredBy", ""))
            if isinstance(scorer, dict):
                scorer = scorer.get("name", {}).get("default", "")
            desc = f"GOAL - {scorer}" if scorer else "GOAL"

            # Add assists
            assists = []
            for key in ["assist1PlayerTotal", "assist2PlayerTotal"]:
                if key in details:
                    assist_data = details[key]
                    if isinstance(assist_data, dict):
                        assist_name = assist_data.get("name", {}).get("default", "")
                        if assist_name:
                            assists.append(assist_name)

            if assists:
                desc += f" (Assists: {', '.join(assists)})"

        elif event_type == "penalty":
            css_class = "play-description play-penalty"
            committed_by = details.get("committedByPlayer", "")
            if isinstance(committed_by, dict):
                committed_by = committed_by.get("name", {}).get("default", "")
            penalty_type = details.get("descKey", "penalty")
            minutes = details.get("duration", 2)
            desc = (
                f"PENALTY - {committed_by}: {penalty_type} ({minutes} min)"
                if committed_by
                else f"PENALTY ({minutes} min)"
            )

        elif event_type == "shot-on-goal":
            shooter = details.get("shootingPlayer", "")
            if isinstance(shooter, dict):
                shooter = shooter.get("name", {}).get("default", "")
            shot_type = details.get("shotType", "")
            desc = f"Shot on goal - {shooter}" if shooter else "Shot on goal"
            if shot_type:
                desc += f" ({shot_type})"

        elif event_type == "stoppage":
            reason = details.get("reason", "")
            desc = f"Stoppage - {reason}" if reason else "Stoppage"

        elif event_type == "faceoff":
            winner = details.get("winningPlayer", "")
            if isinstance(winner, dict):
                winner = winner.get("name", {}).get("default", "")
            desc = f"Faceoff won by {winner}" if winner else "Faceoff"

        text = f"{time_in_period:>6}  {desc}"
        return Label(text, classes=css_class)

    def update_plays(self, plays: list) -> None:
        """Update the plays list and refresh the display."""
        self.plays = plays
        self.refresh(recompose=True)

update_plays(plays)

Update the plays list and refresh the display.

Source code in src/faceoff/widgets/play_by_play.py
175
176
177
178
def update_plays(self, plays: list) -> None:
    """Update the plays list and refresh the display."""
    self.plays = plays
    self.refresh(recompose=True)