diff --git a/plugins.json b/plugins.json index 2c5aab6..fc6d1e6 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-04-10", + "last_updated": "2026-04-14", "plugins": [ { "id": "hello-world", @@ -699,10 +699,10 @@ "plugin_path": "plugins/masters-tournament", "stars": 0, "downloads": 0, - "last_updated": "2026-04-09", + "last_updated": "2026-04-14", "verified": true, "screenshot": "", - "latest_version": "2.3.0" + "latest_version": "2.4.1" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/config_schema.json b/plugins/masters-tournament/config_schema.json index f405056..aa18949 100644 --- a/plugins/masters-tournament/config_schema.json +++ b/plugins/masters-tournament/config_schema.json @@ -51,6 +51,13 @@ "maximum": 256, "description": "Width in pixels of each card when rendered in Vegas scroll mode (the horizontally-scrolling ticker). Set lower to pack more cards into a long panel; set higher for more detail per card. Card height always matches the display height." }, + "post_tournament_display_days": { + "type": "integer", + "default": 1, + "minimum": 0, + "maximum": 14, + "description": "Number of days after the final round to continue showing tournament results (leaderboard, player cards, etc.) before switching to countdown mode. Set to 0 to switch immediately after the tournament ends, or higher to extend the post-tournament display window." + }, "mock_data": { "type": "boolean", "default": false, @@ -143,6 +150,11 @@ "maximum": 60, "description": "Time to show each hole (seconds)" }, + "show_divider": { + "type": "boolean", + "default": true, + "description": "Show the vertical divider line between the hole info and map columns. Set to false for a cleaner single-cell look." + }, "featured_holes": { "type": "array", "items": { @@ -421,6 +433,7 @@ "enabled", "display_duration", "update_interval", + "post_tournament_display_days", "mock_data", "favorite_players", "display_modes", diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index fccd36f..5e6b0c2 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -9,7 +9,7 @@ import logging import os import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from PIL import Image @@ -21,6 +21,7 @@ from masters_renderer_enhanced import MastersRendererEnhanced from logo_loader import MastersLogoLoader from masters_helpers import ( + _masters_thursday, calculate_tournament_countdown, filter_favorite_players, get_detailed_phase, @@ -91,10 +92,18 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._last_update = 0 self._update_interval = config.get("update_interval", 30) + # How many days after the final round to keep showing tournament data + # before the countdown takes over. Default: 1 day. + self._post_tournament_display_days = config.get("post_tournament_display_days", 1) + # Tournament phase — date-driven from live meta when available meta_start, meta_end = self._meta_dates() self._tournament_phase = get_tournament_phase(start_date=meta_start, end_date=meta_end) - self._detailed_phase = get_detailed_phase(start_date=meta_start, end_date=meta_end) + self._detailed_phase = get_detailed_phase( + start_date=meta_start, + end_date=meta_end, + post_tournament_display_days=self._post_tournament_display_days, + ) # Build enabled modes (phase-aware) self.modes = self._build_enabled_modes() @@ -241,7 +250,11 @@ def _build_enabled_modes(self) -> List[str]: proportionally more screen time. """ meta_start, meta_end = self._meta_dates() - phase = get_detailed_phase(start_date=meta_start, end_date=meta_end) + phase = get_detailed_phase( + start_date=meta_start, + end_date=meta_end, + post_tournament_display_days=self._post_tournament_display_days, + ) phase_modes = self.PHASE_MODES.get(phase, self.PHASE_MODES["off-season"]) # Filter by user config (respect per-mode enabled/disabled) @@ -300,7 +313,9 @@ def update(self): if new_modes != self.modes: old_phase = self._detailed_phase self._detailed_phase = get_detailed_phase( - start_date=meta_start, end_date=meta_end + start_date=meta_start, + end_date=meta_end, + post_tournament_display_days=self._post_tournament_display_days, ) self.modes = new_modes self.logger.info( @@ -451,7 +466,12 @@ def _display_course_tour(self, force_clear: bool) -> bool: self._last_hole_advance["course_tour"] = now elif last == 0: self._last_hole_advance["course_tour"] = now - return self._show_image(self.renderer.render_hole_card(self._current_hole)) + show_divider = self.config.get("display_modes", {}).get( + "course_tour", {} + ).get("show_divider", True) + return self._show_image( + self.renderer.render_hole_card(self._current_hole, show_divider=show_divider) + ) def _display_amen_corner(self, force_clear: bool) -> bool: return self._show_image(self.renderer.render_amen_corner()) @@ -474,7 +494,10 @@ def _display_featured_holes(self, force_clear: bool) -> bool: elif last == 0: self._last_hole_advance["featured"] = now hole = featured[self._featured_hole_index % len(featured)] - return self._show_image(self.renderer.render_hole_card(hole)) + show_divider = self.config.get("display_modes", {}).get( + "course_tour", {} + ).get("show_divider", True) + return self._show_image(self.renderer.render_hole_card(hole, show_divider=show_divider)) def _display_schedule(self, force_clear: bool) -> bool: page = self._advance_page("schedule") @@ -527,8 +550,11 @@ def _display_countdown(self, force_clear: bool) -> bool: target = meta["start_date"] else: # Hard fallback — should be unreachable, but keep the screen alive. - now = datetime.utcnow() - target = datetime(now.year, 4, 10, 12, 0, 0) + now = datetime.now(timezone.utc) + year = now.year + target = _masters_thursday(year) + if target <= now: + target = _masters_thursday(year + 1) countdown = calculate_tournament_countdown(target) return self._show_image( self.renderer.render_countdown( @@ -567,9 +593,12 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: if card: cards.append(card) + show_divider = self.config.get("display_modes", {}).get( + "course_tour", {} + ).get("show_divider", True) for hole in range(1, 19): card = self.renderer.render_hole_card( - hole, card_width=cw, card_height=ch, + hole, card_width=cw, card_height=ch, show_divider=show_divider, ) if card: cards.append(card) @@ -619,6 +648,7 @@ def on_config_change(self, new_config): self._page_interval = new_config.get("page_display_duration", 15) self._player_card_interval = new_config.get("player_card_duration", 8) self._scroll_card_width = new_config.get("scroll_card_width", 128) + self._post_tournament_display_days = new_config.get("post_tournament_display_days", 1) self._last_hole_advance.clear() self._last_page_advance.clear() self.modes = self._build_enabled_modes() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 2b4b6c1..1dbbf2f 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.3.0", + "version": "2.4.1", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,16 @@ "height": 64 }, "versions": [ + { + "version": "2.4.1", + "released": "2026-04-14", + "ledmatrix_min_version": "2.0.0" + }, + { + "version": "2.4.0", + "released": "2026-04-10", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.3.0", "released": "2026-04-10", @@ -104,7 +114,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-04-09", + "last_updated": "2026-04-14", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index 9367028..77a4b1c 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -267,16 +267,17 @@ def _format_tee_time_et(cls, iso_value: Optional[str]) -> str: return f"{display_hour}:{minute:02d} {suffix}" def _computed_fallback_meta(self) -> Dict: - """Compute a best-guess Masters window: second Thursday of April. + """Compute a best-guess Masters window using the correct Thursday rule. Used only when ESPN doesn't currently return the Masters (off-season). """ + from masters_helpers import _masters_thursday now = datetime.now(timezone.utc) year = now.year - start = self._second_thursday_of_april(year) + start = _masters_thursday(year) if now > start + timedelta(days=4): - start = self._second_thursday_of_april(year + 1) - # Cover all four calendar days (Thu–Sun) through end-of-day, matching + start = _masters_thursday(year + 1) + # Cover all four calendar days (Thu-Sun) through end-of-day, matching # the normalization applied to ESPN's parsed endDate. end = start + timedelta(days=3, hours=23, minutes=59, seconds=59) return { @@ -290,12 +291,15 @@ def _computed_fallback_meta(self) -> Dict: @staticmethod def _second_thursday_of_april(year: int) -> datetime: - """Second Thursday of April at 12:00 UTC (≈ 8am ET tee-off).""" - d = datetime(year, 4, 1, 12, 0, 0, tzinfo=timezone.utc) - # weekday(): Mon=0 … Thu=3 - days_to_first_thursday = (3 - d.weekday()) % 7 - first_thursday = d + timedelta(days=days_to_first_thursday) - return first_thursday + timedelta(days=7) + """Backwards-compatible alias — delegates to the correct algorithm. + + The original 'second Thursday of April' rule gave wrong dates for + 2022 (Apr 13, actual Apr 7) and 2023 (Apr 13, actual Apr 6). + The correct rule is the Thursday between April 6-12 inclusive. + This method is kept so any external callers are not broken. + """ + from masters_helpers import _masters_thursday + return _masters_thursday(year) # ── Schedule / tee times ───────────────────────────────────── diff --git a/plugins/masters-tournament/masters_helpers.py b/plugins/masters-tournament/masters_helpers.py index 95e5382..e8b59a9 100644 --- a/plugins/masters-tournament/masters_helpers.py +++ b/plugins/masters-tournament/masters_helpers.py @@ -341,6 +341,31 @@ def calculate_scoring_average(rounds: List[Optional[int]]) -> Optional[float]: return sum(valid_rounds) / len(valid_rounds) +def _masters_thursday(year: int) -> datetime: + """Return the Masters Tournament start date (Thursday) for the given year. + + The Masters always starts on the Thursday that falls between April 6 and + April 12 inclusive — the first full Mon-Sun week of April that contains + that Thursday. Verified against historical data: + + 2022 (Apr 1 = Fri) -> Apr 7 + 2023 (Apr 1 = Sat) -> Apr 6 + 2024 (Apr 1 = Mon) -> Apr 11 + 2025 (Apr 1 = Tue) -> Apr 10 + 2026 (Apr 1 = Wed) -> Apr 9 + 2027 (Apr 1 = Thu) -> Apr 8 + 2028 (Apr 1 = Sat) -> Apr 6 + + Returns a timezone-aware datetime at 12:00 UTC (approx. 8 am ET tee-off). + """ + for day in range(6, 13): # April 6 .. April 12 inclusive + d = datetime(year, 4, day, 12, 0, 0, tzinfo=timezone.utc) + if d.weekday() == 3: # Thursday = 3 + return d + # Unreachable — there is always exactly one Thursday in any 7-day span. + raise RuntimeError(f"No Thursday found between April 6-12 for year {year}") + + def _to_eastern(date: Optional[datetime]) -> datetime: """Normalize a datetime to Eastern (Augusta) time.""" if date is None: @@ -373,12 +398,19 @@ def get_tournament_phase( return "practice" return "off-season" - if date.month == 4: - if 7 <= date.day <= 9: - return "practice" - elif 10 <= date.day <= 13: - return "tournament" - + # Fallback: compute the correct Thursday dynamically. + thu = _masters_thursday(date.year) + thu_e = _to_eastern(thu) + thu_date = thu_e.date() + start_date_d = thu_date + end_date_d = thu_date + timedelta(days=3) + practice_start = thu_date - timedelta(days=3) + date_d = date.date() + + if start_date_d <= date_d <= end_date_d: + return "tournament" + if practice_start <= date_d < start_date_d: + return "practice" return "off-season" @@ -386,6 +418,7 @@ def get_detailed_phase( date: Optional[datetime] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, + post_tournament_display_days: int = 1, ) -> str: """ Determine detailed tournament phase including time-of-day awareness. @@ -398,7 +431,8 @@ def get_detailed_phase( "tournament-live" - Tournament day, play in progress (~8am-7pm ET) "tournament-evening" - Tournament day, play finished (~7pm-midnight ET) "tournament-overnight"- Tournament day, overnight (midnight-6am ET) - "post-tournament" - Sunday evening / Monday after Masters + "post-tournament" - Sunday evening through N days after Masters + (controlled by post_tournament_display_days) """ date = _to_eastern(date) @@ -419,7 +453,7 @@ def get_detailed_phase( return "tournament-live" return "tournament-evening" - if date > end_e and (date - end_e) <= timedelta(days=1): + if date > end_e and (date - end_e) <= timedelta(days=post_tournament_display_days): return "post-tournament" delta = start_e - date @@ -429,39 +463,43 @@ def get_detailed_phase( return "pre-tournament" return "off-season" - month = date.month - day = date.day + # Fallback: compute the correct tournament window dynamically using the + # April 6-12 Thursday rule so this stays accurate in future years. + # Use calendar-day comparisons so early-morning hours on tournament days + # (e.g. 6 am Thursday) are not misclassified as "practice". hour = date.hour + thu = _masters_thursday(date.year) + thu_e = _to_eastern(thu) + thu_date = thu_e.date() + sun_date = thu_date + timedelta(days=3) # Sunday = last day + mon_date = thu_date - timedelta(days=3) # Monday = first practice day + date_date = date.date() + + # Tournament days: Thursday through Sunday (calendar day) + if thu_date <= date_date <= sun_date: + if hour < 6: + return "tournament-overnight" + if hour < 8: + return "tournament-morning" + if hour < 19: + return "tournament-live" + return "tournament-evening" + + # Post-tournament: N days after Sunday + if timedelta(0) < (date_date - sun_date) <= timedelta(days=post_tournament_display_days): + return "post-tournament" + + # Practice rounds: Mon-Wed of Masters week + if mon_date <= date_date < thu_date: + return "practice" + + # Pre-tournament: up to 2 weeks before (build anticipation). + # Compare date objects to avoid mixing aware/naive datetimes. + if timedelta(0) < (thu_date - date_date) <= timedelta(days=14): + return "pre-tournament" - # Masters is typically the second full week of April - # Practice: Mon-Wed, Tournament: Thu-Sun - # Adjust these dates each year as needed - if month == 4: - # Week before Masters (build anticipation) - if 1 <= day <= 6: - return "pre-tournament" - - # Practice rounds - if 7 <= day <= 9: - return "practice" - - # Tournament days (Thu=10 through Sun=13) - if 10 <= day <= 13: - if hour < 6: - return "tournament-overnight" - elif hour < 8: - return "tournament-morning" - elif hour < 19: - return "tournament-live" - else: - return "tournament-evening" - - # Monday after - if day == 14: - return "post-tournament" - - # March - countdown month - if month == 3 and day >= 20: + # Late March counts as pre-tournament too + if date.month == 3 and date.day >= 20: return "pre-tournament" return "off-season" diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 1ea1992..7947570 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -923,7 +923,8 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name, def render_hole_card(self, hole_number: int, card_width: Optional[int] = None, - card_height: Optional[int] = None) -> Optional[Image.Image]: + card_height: Optional[int] = None, + show_divider: bool = True) -> Optional[Image.Image]: cw = card_width if card_width is not None else self.width ch = card_height if card_height is not None else self.height hole_info = get_hole_info(hole_number) diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 92b4243..5943cc3 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -226,7 +226,8 @@ def render_player_card(self, player: Dict, def render_hole_card(self, hole_number: int, card_width: Optional[int] = None, - card_height: Optional[int] = None) -> Optional[Image.Image]: + card_height: Optional[int] = None, + show_divider: bool = True) -> Optional[Image.Image]: """Enhanced hole card with two layout modes, chosen by vertical resolution: * **ch >= 48** → existing "big" layout: a single text column on the @@ -259,15 +260,16 @@ def render_hole_card(self, hole_number: int, if ch >= self._HOLE_COMPACT_HEIGHT: return self._render_hole_card_with_image( - img, draw, hole_number, hole_info, cw, ch, + img, draw, hole_number, hole_info, cw, ch, show_divider=show_divider, ) return self._render_hole_card_compact( - img, draw, hole_number, hole_info, cw, ch, + img, draw, hole_number, hole_info, cw, ch, show_divider=show_divider, ) def _render_hole_card_with_image(self, img, draw, hole_number: int, - hole_info: Dict, cw: int, ch: int) -> Image.Image: + hole_info: Dict, cw: int, ch: int, + show_divider: bool = True) -> Image.Image: """Large-canvas layout: single text column on the left + hole image on the right. Text column contents (stacked top to bottom): @@ -284,9 +286,10 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, show_image = left_w < cw - # Left panel background strip - draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) - if show_image: + # Left panel background strip (suppressed when show_divider=False for a unified green look) + if show_divider: + draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) + if show_image and show_divider: draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) line_h = self._text_height(draw, "A", self.font_detail) + 1 @@ -387,7 +390,8 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, def _render_hole_card_compact(self, img, draw, hole_number: int, hole_info: Dict, cw: Optional[int] = None, - ch: Optional[int] = None) -> Image.Image: + ch: Optional[int] = None, + show_divider: bool = True) -> Image.Image: """Compact hole card for vertical resolutions below 48px. All text stacked on the left, course image on the right. @@ -455,9 +459,10 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, fill=COLORS["masters_yellow"], font=text_font) if show_image: - # Divider between text and image - draw.line([(text_w, 0), (text_w, ch - 1)], - fill=COLORS["masters_yellow"]) + # Divider between text and image (suppressed when show_divider=False) + if show_divider: + draw.line([(text_w, 0), (text_w, ch - 1)], + fill=COLORS["masters_yellow"]) # Course image on the right hole_img = self.logo_loader.get_hole_image(