diff --git a/.gitignore b/.gitignore index 46ce8af2f..88b45db96 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,8 @@ BUILD.bazel # Pixlet *.webp *.gif -*.tar.gz \ No newline at end of file +*.tar.gz +apps/homegame/REQUIREMENTS.md +apps/homegame/push_to_tidbyt.ps1 +apps/homegame/push_to_tidbyt.sh +apps/homegame/DEPLOY.md diff --git a/apps/homegame/.gitignore b/apps/homegame/.gitignore new file mode 100644 index 000000000..c0dd7bf58 --- /dev/null +++ b/apps/homegame/.gitignore @@ -0,0 +1,13 @@ +# Build artifacts +*.webp +*.gif + +# Test outputs +test_output/ +tests/test_output/ +tests/*.webp + +# Do NOT ignore golden reference images and screenshots +!tests/golden_images/ +!tests/golden_images/*.webp +!screenshot.webp diff --git a/apps/homegame/DEVELOPMENT.md b/apps/homegame/DEVELOPMENT.md new file mode 100644 index 000000000..6743940c7 --- /dev/null +++ b/apps/homegame/DEVELOPMENT.md @@ -0,0 +1,395 @@ +# HomeGame Development Guide + +## Prerequisites + +- **Pixlet** - The only tool you need! (includes buildifier linter built-in) +- **PowerShell** - For running test scripts (Windows) + +## Development Workflow + +### 1. Local Development & Preview + +```bash +# Serve the app locally with hot reload +pixlet serve homegame.star --port 81 + +# View in browser +# http://localhost:81 +``` + +### 2. Code Quality & Linting + +Pixlet includes **Buildifier**, the official Bazel/Starlark linter, built-in! + +```bash +# Check code quality (format + lint + tests) +pixlet check homegame.star + +# View lint warnings +pixlet lint homegame.star + +# Auto-fix lint issues +pixlet lint --fix homegame.star +``` + +**What does the linter check?** +- ✅ Starlark code formatting (whitespace, indentation) +- ✅ Code style consistency +- ✅ Best practices for Tidbyt apps +- ✅ Buildifier warnings (unused variables, deprecated patterns, etc.) + +**Tip**: Always run `pixlet lint --fix` before committing! + +### 3. Testing + +```powershell +# From the tests/ directory + +# Run all integration tests +.\run_integration_tests.ps1 + +# Keep test images for inspection +.\run_integration_tests.ps1 -KeepImages + +# Update golden reference images (after verifying changes) +.\run_integration_tests.ps1 -UpdateGolden +``` + +### 4. Pre-Commit Checklist + +Before committing changes, ensure: + +1. ✅ **Lint**: `pixlet lint --fix homegame.star` +2. ✅ **Check**: `pixlet check homegame.star` (must pass) +3. ✅ **Test**: `.\tests\run_integration_tests.ps1` (must pass) +4. ✅ **Preview**: `pixlet serve homegame.star` (visual inspection) + +### 5. Pre-Commit Hook (Automated) + +A Git pre-commit hook is **already installed** that automates steps 1 & 2! + +**What it does:** +- ✅ Automatically runs `pixlet lint --fix` on staged `.star` files +- ✅ Runs `pixlet check` to verify code quality +- ✅ Stages auto-fixed files +- ✅ Blocks commit if checks fail + +**Usage:** +Just commit as normal - the hook runs automatically: +```bash +git add homegame.star +git commit -m "Update game logic" +# Hook runs automatically before commit +``` + +**Hook output:** +``` +======================================== + HomeGame Pre-Commit Quality Check +======================================== + +[INFO] Found modified Starlark files: + - apps/homegame/homegame.star + +[LINT] Running pixlet lint --fix... +[OK] Linting complete +[OK] Staged auto-fixed file + +[CHECK] Running pixlet check... +✔️ apps/homegame/homegame.star +[OK] All checks passed + +======================================== + PRE-COMMIT CHECK PASSED ✓ +======================================== +``` + +**Bypass hook** (not recommended): +```bash +git commit --no-verify +``` + +**Verify hook is installed:** +```bash +ls -la .git/hooks/pre-commit +# Should show: -rwxr-xr-x (executable) +``` + +## Buildifier Integration + +### What is Buildifier? + +Buildifier is the official linter for Bazel and Starlark code. It's maintained by the Bazel team and enforces consistent code style. + +**GitHub**: https://github.com/bazelbuild/buildtools/blob/main/buildifier/README.md + +### How Pixlet Uses Buildifier + +As of 2025, Pixlet has **unified tooling** built-in: +- No need to install buildifier separately +- No need to install Make, Go, or golangci-lint +- Just install Pixlet, and you're ready to develop! + +**Key Command**: `pixlet check` runs: +1. **Format check** - Ensures proper Starlark formatting +2. **Lint check** - Runs buildifier with linting rules +3. **App validation** - Tidbyt-specific checks + +### Common Lint Fixes + +Buildifier will automatically fix: +- ✅ Inconsistent indentation (spaces vs tabs) +- ✅ Trailing whitespace +- ✅ Missing blank lines +- ✅ Incorrect import order +- ✅ Unnecessary parentheses +- ✅ Deprecated Starlark patterns + +### Suppressing Warnings + +To suppress specific buildifier warnings: + +```starlark +# buildifier: disable= +problematic_code_here() +``` + +Example: +```starlark +# buildifier: disable=unused-variable +unused_var = get_some_data() +``` + +**Warning**: Only suppress when absolutely necessary! + +## Code Style Guidelines + +### Naming Conventions + +- **Functions**: `snake_case` (e.g., `parse_game_event`) +- **Variables**: `snake_case` (e.g., `our_team`, `is_home_game`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_TEAM_ID`) + +### Documentation + +Always include docstrings: + +```starlark +def my_function(param1, param2): + """ + Brief description of what the function does. + + Args: + param1: Description of param1 + param2: Description of param2 + + Returns: + Description of return value + """ + # Implementation +``` + +### Line Length + +- Prefer lines ≤ 80 characters +- Buildifier will warn on lines > 119 characters + +### Import Order + +Load statements should be alphabetically sorted: + +```starlark +load("cache.star", "cache") +load("encoding/base64.star", "base64") +load("encoding/json.star", "json") +load("http.star", "http") +load("render.star", "render") +load("schema.star", "schema") +load("time.star", "time") +``` + +## Integration Tests + +### Architecture + +The test suite uses **dependency injection** to test production code without duplication: + +``` +Test Runner (PowerShell) + ↓ Generates mock data + ↓ Encodes as base64 + ↓ Passes via config parameter + ↓ +Production App (homegame.star) + ↓ Detects test mode + ↓ Uses mock data instead of API + ↓ Renders actual output + ↓ +Golden Image Comparison + ↓ MD5 hash comparison + ✅ PASS if exact match +``` + +**Benefits**: +- Zero code duplication +- Tests actual production code +- Automatic sync with code changes +- Pixel-perfect visual regression detection + +### Adding New Tests + +1. Add test case to `tests/run_integration_tests.ps1`: + +```powershell +@{ + Name = "my_test" + Description = "Test description" + Expected = "What should be displayed" + Event = @{ + # Mock ESPN API data + } + MockTime = "2025-10-11T20:30:00Z" +} +``` + +2. Run with `-UpdateGolden` to create reference image: + +```powershell +.\run_integration_tests.ps1 -UpdateGolden +``` + +3. Commit the new golden image in `tests/golden_images/` + +## Debugging + +### Print Debugging + +Use `print()` statements (output appears in console): + +```starlark +print("Debug: our_team =", our_team) +print("Debug: game_status =", game_status) +``` + +### Visual Debugging + +Use `pixlet serve` to see changes in real-time: + +```bash +pixlet serve homegame.star --port 81 +# Edit code, save, browser auto-refreshes +``` + +### Test-Specific Debugging + +Keep test images for inspection: + +```powershell +.\run_integration_tests.ps1 -KeepImages +# Images saved in tests/test_output/ +``` + +## Performance Tips + +### API Caching + +```starlark +# Cache API responses for 5 minutes +cached_data = cache.get(cache_key) +if cached_data: + return cached_data + +# Fetch and cache +data = http.get(url).json() +cache.set(cache_key, data, ttl_seconds = 300) +``` + +### Minimize API Calls + +- Use cache aggressively +- Fetch only what you need +- Avoid redundant API requests + +### Test Without API + +Use test injection for development: + +```bash +# No API calls made during testing! +.\run_integration_tests.ps1 +``` + +## Publishing to Tidbyt Community + +Before submitting a PR to `tidbyt/community`: + +1. ✅ Run `pixlet check homegame.star` (must pass) +2. ✅ Run all tests (must pass) +3. ✅ Test on physical Tidbyt device +4. ✅ Update `manifest.yaml` with correct metadata +5. ✅ Ensure `README.md` is up-to-date +6. ✅ Include screenshots/GIFs + +**Submission Process**: +1. Fork `tidbyt/community` +2. Create feature branch +3. Add your app to `apps/` +4. Open pull request +5. Maintainers will review (linting is automated via CI) + +## Useful Resources + +- **Pixlet Docs**: https://tidbyt.dev/docs/ +- **Starlark Spec**: https://github.com/bazelbuild/starlark +- **Buildifier Docs**: https://github.com/bazelbuild/buildtools/blob/main/buildifier/README.md +- **Tidbyt Community**: https://github.com/tidbyt/community +- **ESPN API**: http://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/{team_id} + +## Quick Reference + +```bash +# Development +pixlet serve homegame.star --port 81 + +# Quality +pixlet lint --fix homegame.star +pixlet check homegame.star + +# Testing +.\tests\run_integration_tests.ps1 +.\tests\run_integration_tests.ps1 -KeepImages +.\tests\run_integration_tests.ps1 -UpdateGolden + +# Production +pixlet render homegame.star -o output.webp +pixlet push YOUR_DEVICE_ID homegame.star +``` + +## Troubleshooting + +### "linting failed with exit code: 4" + +**Solution**: Run `pixlet lint --fix homegame.star` to auto-fix issues. + +### Tests fail after code changes + +**Expected!** If you modified the UI: +1. Run tests with `-KeepImages` +2. Inspect images in `test_output/` +3. If correct: `.\run_integration_tests.ps1 -UpdateGolden` +4. Commit new golden images + +### API returns unexpected data + +**Debug**: +1. Add `print()` statements +2. Check ESPN API directly: `http://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/245` +3. Verify API response structure hasn't changed + +### Pixlet command not found + +**Solution**: Add Pixlet to PATH or use full path to executable. + +--- + +**Happy Coding!** 🏈 diff --git a/apps/homegame/DEV_README.md b/apps/homegame/DEV_README.md new file mode 100644 index 000000000..12ca43360 --- /dev/null +++ b/apps/homegame/DEV_README.md @@ -0,0 +1,201 @@ +# HomeGame + +A Tidbyt applet that displays upcoming college football game information with home/away indicators, countdown timers, and live scores. + +## Features + +- **Team Selection**: Choose from 76+ popular college football teams via dropdown +- **Dynamic Styling**: Game-day solid backgrounds vs. non-game-day colored text +- **Live Countdown**: Real-time countdown timer on game day +- **Live Scores**: Quarter-by-quarter scoring with period indicators (Q1-Q4, OT, FINAL) +- **Home/Away Indicator**: Visual distinction between home (red) and away (green) games +- **Timezone Support**: Location-based timezone detection + +## Setup + +### Prerequisites + +1. Install [Pixlet](https://github.com/tidbyt/pixlet) +2. Ensure pixlet is in your PATH +3. Login to your Tidbyt account: + ```bash + pixlet login + ``` +4. Find your device ID: + ```bash + pixlet devices + ``` + +### Configuration + +The app uses a user-friendly configuration interface: + +- **Team Selection**: Choose from dropdown of popular teams (SEC, Big Ten, Big 12, ACC, Pac-12, Independents) +- **Custom Teams**: Select "Other (Enter Team ID)" to enter any ESPN team ID +- **Location**: Use location picker for automatic timezone detection + +## Local Development + +### Live Preview + +Run the app locally with interactive configuration: + +```bash +pixlet serve homegame.star +``` + +View at: http://localhost:8080 + +Configure your team and location in the web interface. + +### Manual Testing + +Render with specific configuration: + +```bash +# Texas A&M example +pixlet render homegame.star team_dropdown=245 + +# Alabama example +pixlet render homegame.star team_dropdown=333 +``` + +## Deployment + +### Quick Deploy (Recommended) + +Use the provided push scripts for one-command deployment: + +**Windows (PowerShell)**: +```powershell +.\push_to_tidbyt.ps1 -DeviceId "your-device-id" +``` + +**Linux/Mac (Bash)**: +```bash +./push_to_tidbyt.sh your-device-id +``` + +### Advanced Options + +**Push with custom team**: +```powershell +# Windows +.\push_to_tidbyt.ps1 -DeviceId "abc123" -TeamId "333" + +# Linux/Mac +./push_to_tidbyt.sh abc123 --team-id 333 +``` + +**Push to background rotation**: +```powershell +# Windows +.\push_to_tidbyt.ps1 -DeviceId "abc123" -Background + +# Linux/Mac +./push_to_tidbyt.sh abc123 --background +``` + +**View all options**: +```powershell +# Windows +Get-Help .\push_to_tidbyt.ps1 -Full + +# Linux/Mac +./push_to_tidbyt.sh --help +``` + +### Manual Deployment + +If you prefer manual control: + +1. **Render the app**: + ```bash + pixlet render homegame.star team_dropdown=245 --output homegame.webp + ``` + +2. **Push to device**: + ```bash + pixlet push homegame.webp --installation-id homegame + ``` + +## Display States + +The app has three distinct display modes: + +1. **Future Game** (non-game day): + - Date and time of next game + - Colored text on black background + - No border + +2. **Countdown** (game day, pre-kickoff): + - Live countdown timer (hours/minutes until kickoff) + - Kickoff time displayed + - Solid red (home) or green (away) background + +3. **In Progress** (live game): + - Current score (our team vs opponent) + - Period indicator (Q1, Q2, Q3, Q4, OT, FINAL) + - Yellow text for active quarters, white for final + +## Testing + +The app includes comprehensive integration tests. See [DEVELOPMENT.md](DEVELOPMENT.md) for testing documentation. + +Run tests: +```powershell +cd tests +.\run_integration_tests.ps1 +``` + +## Popular Team IDs + +| Conference | Team | ID | +|------------|------|-----| +| SEC | Alabama | 333 | +| SEC | Texas A&M | 245 | +| SEC | Georgia | 61 | +| SEC | LSU | 99 | +| Big Ten | Ohio State | 194 | +| Big Ten | Michigan | 130 | +| Big Ten | Penn State | 213 | +| Big 12 | Oklahoma State | 197 | +| Big 12 | TCU | 2628 | +| ACC | Clemson | 228 | +| ACC | FSU | 52 | +| Independent | Notre Dame | 87 | + +Full list of 76+ teams available in the dropdown selector. + +## Troubleshooting + +### Push fails with authentication error +- Run `pixlet login` to authenticate +- Or get API token from https://tidbyt.com/settings/account +- Use `--api-token` flag with push script + +### Can't find device ID +- Run `pixlet devices` to list all your Tidbyt devices +- Copy the device ID (hexadecimal string) + +### Team not showing games +- Verify team ID at ESPN.com (check URL on team page) +- Try alternate endpoint: http://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/[TEAM_ID] + +### Wrong timezone +- Use location picker in configuration UI +- Timezone is automatically extracted from location + +## Documentation + +- [REQUIREMENTS.md](REQUIREMENTS.md) - Detailed requirements and specifications +- [DEVELOPMENT.md](DEVELOPMENT.md) - Development workflow, linting, and testing +- [todo.md](todo.md) - Planned improvements and roadmap + +## Author + +tscott98 + +## License + +See repository root for license information. diff --git a/apps/homegame/README.md b/apps/homegame/README.md new file mode 100644 index 000000000..84d286a34 --- /dev/null +++ b/apps/homegame/README.md @@ -0,0 +1,37 @@ +# HomeGame + +Never miss your team's next game! HomeGame displays upcoming college football game information on your Tidbyt with live countdowns, scores, and home/away indicators. + +![HomeGame Screenshot](screenshot.webp) + +## Features + +- **76+ College Teams** - Select from dropdown or enter any ESPN team ID +- **Live Countdown** - Real-time timer on game day +- **Live Scores** - Quarter-by-quarter updates during games +- **Home/Away Indicator** - Visual distinction between home (red) and away (green) games +- **Smart Display** - Automatically shows date, countdown, or live score based on game status + +## Display Modes + +The app adapts based on game timing: + +- **Upcoming**: Shows game date and kickoff time +- **Game Day**: Live countdown with solid color background +- **In Progress**: Real-time scores with quarter/period (Q1-Q4, OT, FINAL) + + +## Configuration + +Choose your team from the dropdown (SEC, Big Ten, Big 12, ACC, Pac-12, and more) or enter a custom ESPN team ID. Set your location for accurate game times. + + +## Documentation + +- **[DEPLOY.md](DEPLOY.md)** - Deployment guide and troubleshooting +- **[DEV_README.md](DEV_README.md)** - Full documentation for developers +- **[DEVELOPMENT.md](DEVELOPMENT.md)** - Development workflow and testing + +## Author + +Created by tscott98 and Claude 🤖 diff --git a/apps/homegame/homegame.star b/apps/homegame/homegame.star new file mode 100644 index 000000000..9f2c5ee5c --- /dev/null +++ b/apps/homegame/homegame.star @@ -0,0 +1,742 @@ +""" +Applet: HomeGame +Summary: Upcoming college FB games +Description: Displays upcoming college football game information with home/away indicator. +Author: tscott98 +""" + +load("cache.star", "cache") +load("encoding/base64.star", "base64") +load("encoding/json.star", "json") +load("http.star", "http") +load("render.star", "render") +load("schema.star", "schema") +load("time.star", "time") + +# ESPN API endpoint for college football +ESPN_API_BASE = "http://site.api.espn.com/apis/site/v2/sports/football/college-football" + +# Default configuration +DEFAULT_TEAM_ID = "245" # Example: Texas A&M +DEFAULT_TIMEZONE = "America/Chicago" +DEFAULT_LOCATION = """ +{ + "lat": "30.62", + "lng": "-96.33", + "description": "College Station, TX, USA", + "locality": "College Station", + "place_id": "ChIJn2tQoqvFRIYRJWQX3VJQvOY", + "timezone": "America/Chicago" +} +""" + +def main(config): + """ + Main entry point for the HomeGame applet. + + Args: + config: Configuration object with user settings + + Returns: + render.Root object with the display layout + """ + + # Get team ID from dropdown or custom field + team_dropdown = config.get("team_dropdown", DEFAULT_TEAM_ID) + if team_dropdown == "custom": + team_id = config.get("custom_team_id", DEFAULT_TEAM_ID) + else: + team_id = team_dropdown + + # Get timezone from location JSON + location = json.decode(config.get("location", DEFAULT_LOCATION)) + timezone = location.get("timezone", DEFAULT_TIMEZONE) + + # TEST INJECTION POINT - allows testing without API calls + # When _test_event_b64 is provided, decode base64, parse JSON, skip API + test_event_b64 = config.get("_test_event_b64", None) + test_time_str = config.get("_test_time", None) + + # Parse mock time if provided + mock_now = None + if test_time_str: + mock_now = time.parse_time(test_time_str, format = "2006-01-02T15:04:05Z07:00").in_location(timezone) + + if test_event_b64: + # Test mode - decode base64, parse JSON, then parse mock event + test_event_json = base64.decode(test_event_b64) + test_event = json.decode(test_event_json) + game_data = parse_game_event(test_event, team_id, timezone, mock_now) + else: + # Production mode - fetch from API + game_data = get_next_game(team_id, timezone) + + # Handle error or no game found + if game_data == None: + return render_error("No upcoming game found") + + # Parse game data + our_team = game_data.get("home_team", "TEAM") # "home_team" key actually contains our team + opponent = game_data.get("away_team", "OPP") # "away_team" key actually contains opponent + is_home_game = game_data.get("is_home_game", True) + kickoff_date = game_data.get("kickoff_date", "") + kickoff_time = game_data.get("kickoff_time", "") + game_status = game_data.get("status", "upcoming") # upcoming, countdown, in_progress + countdown_text = game_data.get("countdown_text", "") + our_score = game_data.get("our_score", 0) + opp_score = game_data.get("opp_score", 0) + period = game_data.get("period", "") + is_final = game_data.get("is_final", False) + + # Render the display + return render_game_display( + our_team, + opponent, + is_home_game, + kickoff_date, + kickoff_time, + game_status, + countdown_text, + our_score, + opp_score, + period, + is_final, + ) + +def get_next_game(team_id, timezone): + """ + Fetches the next scheduled game for the specified team. + + Args: + team_id: ESPN team ID for the college football team + timezone: User's timezone for time conversion + + Returns: + Dictionary with game data or None if no game found + """ + + # Create cache key + cache_key = "game_data_%s" % team_id + + # Check cache first (cache for 5 minutes) + cached_data = cache.get(cache_key) + if cached_data != None: + return json.decode(cached_data) + + # Fetch team schedule from ESPN API + url = "%s/teams/%s" % (ESPN_API_BASE, team_id) + + # Make HTTP request with error handling + response = http.get(url, ttl_seconds = 300) + + if response.status_code != 200: + print("Error fetching team data: HTTP %d" % response.status_code) + return None + + # Parse response + team_data = response.json() + + # Extract next event from team data + # Note: The API structure may vary, this is a best-effort parse + if not team_data or "team" not in team_data: + print("Invalid team data structure") + return None + + team_info = team_data.get("team", {}) + next_event = team_info.get("nextEvent", None) + + if next_event == None: + # Try alternate API endpoint - scoreboard + return get_next_game_from_scoreboard(team_id, timezone) + + # Parse next event + game_data = parse_game_event(next_event, team_id, timezone) + + # Cache the result + if game_data: + cache.set(cache_key, json.encode(game_data), ttl_seconds = 300) + + return game_data + +def get_next_game_from_scoreboard(team_id, timezone): + """ + Fallback method to fetch game from scoreboard API. + + Args: + team_id: ESPN team ID + timezone: User's timezone for time conversion + + Returns: + Dictionary with game data or None + """ + + # Get current date and next 30 days + now = time.now() + end_date = now + time.parse_duration("720h") # 30 days + + date_range = "%s-%s" % ( + now.format("20060102"), + end_date.format("20060102"), + ) + + url = "%s/scoreboard?dates=%s&groups=80" % (ESPN_API_BASE, date_range) + + response = http.get(url, ttl_seconds = 300) + + if response.status_code != 200: + print("Error fetching scoreboard: HTTP %d" % response.status_code) + return None + + scoreboard_data = response.json() + events = scoreboard_data.get("events", []) + + # Find the next game for this team + for event in events: + competitions = event.get("competitions", []) + for competition in competitions: + competitors = competition.get("competitors", []) + + # Check if this game involves our team + for competitor in competitors: + if competitor.get("id") == team_id or competitor.get("team", {}).get("id") == team_id: + return parse_game_event(event, team_id, timezone) + + return None + +def parse_game_event(event, team_id, timezone, mock_now = None): + """ + Parses game event data from ESPN API. + + Args: + event: Event data from ESPN API + team_id: Our team's ID to determine home/away + timezone: User's timezone for time conversion + mock_now: Optional mock time for testing (Time object) + + Returns: + Dictionary with parsed game data + """ + + # Handle both dict and list access patterns + if type(event) == "list": + if len(event) == 0: + return None + event = event[0] + + competitions = event.get("competitions", []) + if len(competitions) == 0: + return None + + competition = competitions[0] + + # Safely access competitors + if type(competition) == "dict": + competitors = competition.get("competitors", []) + else: + return None + + # Find home and away teams + home_team_data = None + away_team_data = None + is_home_game = False + our_team_data = None + opponent_team_data = None + + for competitor in competitors: + home_away = competitor.get("homeAway", "") + team = competitor.get("team", {}) + comp_team_id = str(team.get("id", "")) + + if home_away == "home": + home_team_data = team + elif home_away == "away": + away_team_data = team + + # Check if this is our team + if comp_team_id == str(team_id): + our_team_data = team + is_home_game = (home_away == "home") + else: + opponent_team_data = team + + if not home_team_data or not away_team_data: + return None + + if not our_team_data or not opponent_team_data: + return None + + # Get team names (use abbreviation for compact display) + # Always show: OUR_TEAM vs OPPONENT + our_team = our_team_data.get("abbreviation", our_team_data.get("name", "TEAM")) + opponent = opponent_team_data.get("abbreviation", opponent_team_data.get("name", "OPP")) + + # Get game date/time + # ESPN API returns time in UTC (with "Z" suffix) + date_str = event.get("date", "") + if date_str: + # Parse the time from ESPN + if date_str.endswith("Z") and date_str.count(":") == 1: + # Format: "YYYY-MM-DDTHH:MMZ" (no seconds) + parsed_time = time.parse_time(date_str, format = "2006-01-02T15:04Z") + else: + # Try RFC3339 format (with seconds) + parsed_time = time.parse_time(date_str) + + # Convert from UTC to user's local timezone + game_time = parsed_time.in_location(timezone) + else: + game_time = time.now().in_location(timezone) + + # Determine game status + status_data = competition.get("status", {}) + status_type = status_data.get("type", {}).get("name", "scheduled") + + game_status = "upcoming" + countdown_text = "" + + # Use mock time if provided (for testing), otherwise use current time + now = mock_now if mock_now else time.now().in_location(timezone) + time_until_game = game_time - now + + # Check if game is in progress + if status_type == "in" or status_type == "STATUS_IN_PROGRESS": + game_status = "in_progress" + + # Check if it's game day (same calendar day) + # Compare dates: game date vs current date in same timezone + game_date = game_time.format("2006-01-02") + current_date = now.format("2006-01-02") + + if game_date == current_date and time_until_game.seconds > 0: + # It's game day and game hasn't started yet - show countdown + game_status = "countdown" + countdown_text = format_countdown(time_until_game) + elif game_date == current_date and time_until_game.seconds <= 0: + # It's game day and game time has passed - check if in progress + # (If we got here, status_type didn't indicate in progress, so treat as upcoming) + game_status = "countdown" + countdown_text = "0m" + + # Format kickoff time - split into date and time for flexible display + kickoff_date = game_time.format("Jan 2") + kickoff_time = game_time.format("3:04 PM") + + # Extract scores and period for in-progress games + our_score = 0 + opp_score = 0 + period = "" + is_final = False + + if game_status == "in_progress": + # Get scores from competitors + for competitor in competitors: + comp_team_id = str(competitor.get("team", {}).get("id", "")) + score = competitor.get("score", "0") + + if comp_team_id == str(team_id): + our_score = int(score) + else: + opp_score = int(score) + + # Get period/quarter information + period_num = status_data.get("period", 0) + status_detail = status_data.get("type", {}).get("detail", "") + is_final = (status_type == "post" or "final" in status_detail.lower()) + + if is_final: + period = "FINAL" + elif period_num > 0: + # College football has 4 quarters + if period_num == 1: + period = "Q1" + elif period_num == 2: + period = "Q2" + elif period_num == 3: + period = "Q3" + elif period_num == 4: + period = "Q4" + elif period_num > 4: + period = "OT" + str(period_num - 4) if period_num > 5 else "OT" + + return { + "home_team": our_team, + "away_team": opponent, + "is_home_game": is_home_game, + "kickoff_date": kickoff_date, + "kickoff_time": kickoff_time, + "status": game_status, + "countdown_text": countdown_text, + "our_score": our_score, + "opp_score": opp_score, + "period": period, + "is_final": is_final, + } + +def format_countdown(duration): + """ + Formats time duration as countdown string. + + Args: + duration: time.duration object + + Returns: + Formatted countdown string (e.g., "2h 30m") + """ + + hours = int(duration.hours) + minutes = int(duration.minutes % 60) + + if hours > 0: + return "%dh %dm" % (hours, minutes) + else: + return "%dm" % minutes + +def render_game_display(our_team, opponent, is_home_game, kickoff_date, kickoff_time, game_status, countdown_text, our_score, opp_score, period, is_final): + """ + Renders the game display layout with context-aware date/time formatting. + + Args: + our_team: Our team name/abbreviation + opponent: Opponent team name/abbreviation + is_home_game: Boolean indicating if it's a home game for our team + kickoff_date: Formatted kickoff date (e.g., "Oct 12") + kickoff_time: Formatted kickoff time (e.g., "3:30 PM") + game_status: Game status (upcoming, countdown, in_progress) + countdown_text: Countdown text if applicable + our_score: Our team's score (for in_progress games) + opp_score: Opponent's score (for in_progress games) + period: Game period/quarter (Q1, Q2, Q3, Q4, OT, FINAL) + is_final: Whether the game is final + + Returns: + render.Root object + """ + + # Determine status color and text based on game day + is_game_day = (game_status == "countdown" or game_status == "in_progress") + + if is_game_day: + # Game day: Use solid color backgrounds + if is_home_game: + status_bg_color = "#FF0000" # RED solid for HOME + status_text_color = "#000000" # Black text + status_text = "HOME" + else: + status_bg_color = "#00FF00" # GREEN solid for AWAY + status_text_color = "#000000" # Black text + status_text = "AWAY" + + # Render Home/Away indicator with solid background + status_indicator = render.Box( + width = 64, + height = 12, + color = status_bg_color, + child = render.Text( + content = status_text, + font = "6x13", + color = status_text_color, + ), + ) + else: + # Not game day: Use colored text on black background (no border) + if is_home_game: + status_text_color = "#FF0000" # RED text for HOME + status_text = "HOME" + else: + status_text_color = "#00FF00" # GREEN text for AWAY + status_text = "AWAY" + + # Render Home/Away indicator with colored text, no border + status_indicator = render.Box( + width = 64, + height = 12, + color = "#000000", # Black background + child = render.Text( + content = status_text, + font = "6x13", + color = status_text_color, + ), + ) + + # Build display children + children = [ + # Team matchup: OUR_TEAM vs OPPONENT + render.Row( + expanded = True, + main_align = "space_between", + cross_align = "center", + children = [ + render.Text( + content = our_team, + font = "tb-8", + color = "#FFFFFF", + ), + render.Text( + content = " vs ", + font = "tb-8", + color = "#AAAAAA", + ), + render.Text( + content = opponent, + font = "tb-8", + color = "#FFFFFF", + ), + ], + ), + render.Box(width = 64, height = 2), # Spacer + status_indicator, + render.Box(width = 64, height = 2), # Spacer + ] + + # Add bottom row content based on game state + if game_status == "in_progress": + # Game in progress: Show scores on left/right, period in center + children.append( + render.Row( + expanded = True, + main_align = "space_between", + cross_align = "center", + children = [ + # Our score (left) + render.Text( + content = str(our_score), + font = "tb-8", + color = "#FFFFFF", + ), + # Period/quarter (center) + render.Text( + content = period, + font = "tb-8", + color = "#FFFF00" if not is_final else "#FFFFFF", + ), + # Opponent score (right) + render.Text( + content = str(opp_score), + font = "tb-8", + color = "#FFFFFF", + ), + ], + ), + ) + elif game_status == "countdown": + # Game day (countdown): Show countdown timer and kickoff time on same line + children.append( + render.Row( + expanded = True, + main_align = "space_between", + cross_align = "center", + children = [ + render.Text( + content = countdown_text, + font = "tb-8", + color = "#FFFF00", + ), + render.Text( + content = kickoff_time, + font = "tb-8", + color = "#FFFFFF", + ), + ], + ), + ) + else: + # Not game day: Show date and time on single line + children.append( + render.Text( + content = kickoff_date + " " + kickoff_time, + font = "tb-8", + color = "#FFFFFF", + ), + ) + + return render.Root( + child = render.Box( + child = render.Column( + expanded = True, + main_align = "start", + cross_align = "center", + children = children, + ), + ), + ) + +def render_error(message): + """ + Renders an error message. + + Args: + message: Error message to display + + Returns: + render.Root object + """ + + return render.Root( + child = render.Box( + child = render.Column( + expanded = True, + main_align = "center", + cross_align = "center", + children = [ + render.Text( + content = "ERROR", + font = "6x13", + color = "#FF0000", + ), + render.Box(width = 64, height = 2), + render.Text( + content = message, + font = "tb-8", + color = "#FFFFFF", + ), + ], + ), + ), + ) + +def team_id_handler(team_dropdown): + """ + Handler for team selection. If 'custom' is selected, shows text input + for custom team ID. + + Args: + team_dropdown: Selected team value from dropdown + + Returns: + List of schema fields for custom team ID input, or empty list + """ + + if team_dropdown == "custom": + return [ + schema.Text( + id = "custom_team_id", + name = "Custom Team ID", + desc = "Enter ESPN Team ID (e.g., 245 for Texas A&M)", + icon = "hashtag", + default = DEFAULT_TEAM_ID, + ), + ] + return [] + +def get_popular_teams(): + """ + Returns list of popular college football teams for dropdown selection. + + Includes Power 5 conferences (SEC, Big Ten, Big 12, ACC, Pac-12) plus + major independents and popular teams. + + Returns: + List of schema.Option objects for team selection + """ + + return [ + # SEC + schema.Option(display = "Alabama Crimson Tide", value = "333"), + schema.Option(display = "Arkansas Razorbacks", value = "8"), + schema.Option(display = "Auburn Tigers", value = "2"), + schema.Option(display = "Florida Gators", value = "57"), + schema.Option(display = "Georgia Bulldogs", value = "61"), + schema.Option(display = "Kentucky Wildcats", value = "96"), + schema.Option(display = "LSU Tigers", value = "99"), + schema.Option(display = "Mississippi State Bulldogs", value = "344"), + schema.Option(display = "Missouri Tigers", value = "142"), + schema.Option(display = "Ole Miss Rebels", value = "145"), + schema.Option(display = "South Carolina Gamecocks", value = "2579"), + schema.Option(display = "Tennessee Volunteers", value = "2633"), + schema.Option(display = "Texas A&M Aggies", value = "245"), + schema.Option(display = "Texas Longhorns", value = "251"), + schema.Option(display = "Vanderbilt Commodores", value = "238"), + # Big Ten + schema.Option(display = "Illinois Fighting Illini", value = "356"), + schema.Option(display = "Indiana Hoosiers", value = "84"), + schema.Option(display = "Iowa Hawkeyes", value = "2294"), + schema.Option(display = "Maryland Terrapins", value = "120"), + schema.Option(display = "Michigan Wolverines", value = "130"), + schema.Option(display = "Michigan State Spartans", value = "127"), + schema.Option(display = "Minnesota Golden Gophers", value = "135"), + schema.Option(display = "Nebraska Cornhuskers", value = "158"), + schema.Option(display = "Northwestern Wildcats", value = "77"), + schema.Option(display = "Ohio State Buckeyes", value = "194"), + schema.Option(display = "Oregon Ducks", value = "2483"), + schema.Option(display = "Penn State Nittany Lions", value = "213"), + schema.Option(display = "Purdue Boilermakers", value = "2509"), + schema.Option(display = "Rutgers Scarlet Knights", value = "164"), + schema.Option(display = "UCLA Bruins", value = "26"), + schema.Option(display = "USC Trojans", value = "30"), + schema.Option(display = "Washington Huskies", value = "264"), + schema.Option(display = "Wisconsin Badgers", value = "275"), + # Big 12 + schema.Option(display = "Arizona Wildcats", value = "12"), + schema.Option(display = "Arizona State Sun Devils", value = "9"), + schema.Option(display = "Baylor Bears", value = "239"), + schema.Option(display = "BYU Cougars", value = "252"), + schema.Option(display = "Cincinnati Bearcats", value = "2132"), + schema.Option(display = "Colorado Buffaloes", value = "38"), + schema.Option(display = "Houston Cougars", value = "248"), + schema.Option(display = "Iowa State Cyclones", value = "66"), + schema.Option(display = "Kansas Jayhawks", value = "2305"), + schema.Option(display = "Kansas State Wildcats", value = "2306"), + schema.Option(display = "Oklahoma State Cowboys", value = "197"), + schema.Option(display = "TCU Horned Frogs", value = "2628"), + schema.Option(display = "Texas Tech Red Raiders", value = "2641"), + schema.Option(display = "UCF Knights", value = "2116"), + schema.Option(display = "Utah Utes", value = "254"), + schema.Option(display = "West Virginia Mountaineers", value = "277"), + # ACC + schema.Option(display = "Boston College Eagles", value = "103"), + schema.Option(display = "Clemson Tigers", value = "228"), + schema.Option(display = "Duke Blue Devils", value = "150"), + schema.Option(display = "Florida State Seminoles", value = "52"), + schema.Option(display = "Georgia Tech Yellow Jackets", value = "59"), + schema.Option(display = "Louisville Cardinals", value = "97"), + schema.Option(display = "Miami Hurricanes", value = "2390"), + schema.Option(display = "NC State Wolfpack", value = "152"), + schema.Option(display = "North Carolina Tar Heels", value = "153"), + schema.Option(display = "Pittsburgh Panthers", value = "221"), + schema.Option(display = "Syracuse Orange", value = "183"), + schema.Option(display = "Virginia Cavaliers", value = "258"), + schema.Option(display = "Virginia Tech Hokies", value = "259"), + schema.Option(display = "Wake Forest Demon Deacons", value = "154"), + # Pac-12 / Other + schema.Option(display = "California Golden Bears", value = "25"), + schema.Option(display = "Stanford Cardinal", value = "24"), + schema.Option(display = "Washington State Cougars", value = "265"), + # Independents + schema.Option(display = "Army Black Knights", value = "349"), + schema.Option(display = "Notre Dame Fighting Irish", value = "87"), + # Custom option + schema.Option(display = "Other (Enter Team ID)", value = "custom"), + ] + +def get_schema(): + """ + Defines the configuration schema for user customization. + + Returns: + schema.Schema object + """ + + return schema.Schema( + version = "1", + fields = [ + schema.Dropdown( + id = "team_dropdown", + name = "Select Team", + desc = "Choose your college football team", + icon = "football", + default = DEFAULT_TEAM_ID, + options = get_popular_teams(), + ), + schema.Generated( + id = "team_id", + source = "team_dropdown", + handler = team_id_handler, + ), + schema.Location( + id = "location", + name = "Location", + desc = "Your location (used for timezone)", + icon = "locationDot", + ), + ], + ) diff --git a/apps/homegame/install-hooks.sh b/apps/homegame/install-hooks.sh new file mode 100644 index 000000000..cb780c916 --- /dev/null +++ b/apps/homegame/install-hooks.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# +# Install Git hooks for HomeGame development +# +# This script copies the pre-commit hook to .git/hooks/ +# Run this once after cloning the repository +# + +set -e + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} HomeGame Git Hooks Installation${NC}" +echo -e "${BLUE}========================================${NC}\n" + +# Find git root +GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) + +if [ -z "$GIT_ROOT" ]; then + echo -e "${YELLOW}[ERROR] Not in a git repository${NC}" + exit 1 +fi + +HOOKS_DIR="$GIT_ROOT/.git/hooks" +HOOK_SOURCE="$(dirname "$0")/../../.git/hooks/pre-commit" +HOOK_DEST="$HOOKS_DIR/pre-commit" + +echo -e "${BLUE}[INFO] Git root: $GIT_ROOT${NC}" +echo -e "${BLUE}[INFO] Hooks directory: $HOOKS_DIR${NC}\n" + +# Check if pre-commit already exists +if [ -f "$HOOK_DEST" ]; then + echo -e "${YELLOW}[WARN] pre-commit hook already exists${NC}" + read -p "Overwrite? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}[INFO] Installation cancelled${NC}" + exit 0 + fi +fi + +# The hook is already in .git/hooks from our setup +if [ -f "$HOOKS_DIR/pre-commit" ]; then + chmod +x "$HOOKS_DIR/pre-commit" + echo -e "${GREEN}[OK] Pre-commit hook is installed and executable${NC}\n" +else + echo -e "${YELLOW}[WARN] Pre-commit hook not found at $HOOKS_DIR/pre-commit${NC}" + echo -e "${YELLOW}[INFO] You may need to create it manually${NC}\n" + exit 1 +fi + +# Test the hook +echo -e "${BLUE}[INFO] Testing pre-commit hook...${NC}\n" +if [ -x "$HOOKS_DIR/pre-commit" ]; then + echo -e "${GREEN}[OK] Hook is executable and ready to use${NC}" +else + echo -e "${YELLOW}[WARN] Hook exists but is not executable${NC}" + chmod +x "$HOOKS_DIR/pre-commit" + echo -e "${GREEN}[OK] Made hook executable${NC}" +fi + +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN} Installation Complete!${NC}" +echo -e "${GREEN}========================================${NC}\n" + +echo -e "${BLUE}The pre-commit hook will now:${NC}" +echo -e " • Auto-fix linting issues (buildifier)" +echo -e " • Verify code passes pixlet check" +echo -e " • Stage auto-fixed files" +echo -e "" +echo -e "${YELLOW}To bypass the hook (not recommended):${NC}" +echo -e " git commit --no-verify" +echo -e "" diff --git a/apps/homegame/manifest.yaml b/apps/homegame/manifest.yaml new file mode 100644 index 000000000..1cb000154 --- /dev/null +++ b/apps/homegame/manifest.yaml @@ -0,0 +1,6 @@ +--- +id: homegame +name: HomeGame +summary: Upcoming college FB games +desc: Displays upcoming college football game information with home/away indicator. +author: tscott98 diff --git a/apps/homegame/screenshot.webp b/apps/homegame/screenshot.webp new file mode 100644 index 000000000..99ea7d54b Binary files /dev/null and b/apps/homegame/screenshot.webp differ diff --git a/apps/homegame/tests/README.md b/apps/homegame/tests/README.md new file mode 100644 index 000000000..c49b164bf --- /dev/null +++ b/apps/homegame/tests/README.md @@ -0,0 +1,134 @@ +# HomeGame Integration Tests + +## Overview + +This directory contains **integration tests** that test the **actual production code** in `homegame.star` without code duplication. + +### Key Features + +- ✅ **Zero Code Duplication** - Tests inject mock data into production app +- ✅ **Deterministic** - Uses fixed mock time for reproducible results +- ✅ **Visual Regression** - Compares output against golden reference images +- ✅ **Fast** - No API calls, runs in seconds +- ✅ **CI-Ready** - Exit codes for automation + +## How It Works + +1. **Test Runner** (`run_integration_tests.ps1`) generates mock ESPN API data as JSON +2. **JSON Encoding** - Converts to base64 to avoid command-line escaping issues +3. **Config Injection** - Passes mock data via `_test_event_b64` config parameter +4. **Production Code** - `homegame.star` detects test config and uses mock data instead of API +5. **Golden Comparison** - Compares rendered WebP against reference images +6. **Result** - Reports pass/fail with pixel-perfect validation + +## Running Tests + +### All Tests (Automated) + +```powershell +# From homegame/tests directory +.\run_integration_tests.ps1 + +# Keep test images for inspection +.\run_integration_tests.ps1 -KeepImages + +# Update golden reference images (after verifying changes are correct) +.\run_integration_tests.ps1 -UpdateGolden +``` + +### Test Cases + +| Test | Description | Mock Time | Expected Output | +|------|-------------|-----------|-----------------| +| `future` | Future game (Oct 19) | Oct 11, 3:30 PM | Date/time on separate lines | +| `countdown_home` | Home game countdown | Oct 11, 3:30 PM | RED background, "2h 30m" countdown | +| `countdown_away` | Away game countdown | Oct 11, 3:30 PM | GREEN background, "2h 0m" countdown | +| `in_progress` | Game in progress | Oct 11, 6:30 PM | Yellow "IN PROGRESS" text | + +## Golden Images + +The `golden_images/` directory contains reference images for visual regression testing. + +**Important**: +- ✅ DO commit golden images to Git (they're small and essential) +- ⚠️ Only update via `-UpdateGolden` after manually verifying changes +- 🔍 Review diffs carefully when golden images change + +## Test Architecture + +### No Code Duplication + +Unlike typical Tidbyt tests that duplicate business logic, this approach: + +1. **Injects test data** via config parameters +2. **Tests actual production code** - same code path users hit +3. **Stays in sync automatically** - changes to production code are immediately tested + +### Time Mocking + +Tests use `_test_time` parameter to freeze time: + +```powershell +_test_time=2025-10-11T20:30:00Z # Fixed: Oct 11, 2025 @ 3:30 PM Central +``` + +This ensures: +- Countdown calculations are deterministic +- Tests produce identical output every run +- Can test edge cases (midnight, exact kickoff time, etc.) + +### Visual Regression + +Tests compare MD5 hashes of rendered images: + +- **Exact Match** ✅ - Pixel-perfect, no changes +- **Mismatch** ❌ - Visual regression detected + +This catches: +- Color changes (HOME=RED, AWAY=GREEN) +- Layout shifts +- Text changes +- Font/spacing issues + +## Troubleshooting + +### Tests Fail After Code Changes + +**Expected!** If you modified `homegame.star`: + +1. Run tests: `.\run_integration_tests.ps1 -KeepImages` +2. Inspect images in `test_output/` +3. If changes look correct: `.\run_integration_tests.ps1 -UpdateGolden` +4. Commit new golden images + +### JSON Encoding Issues + +Tests use base64 encoding to avoid PowerShell command-line escaping issues. If you see JSON parse errors, check the base64 encoding logic. + +### Time Zone Issues + +Mock time is in UTC (`Z` suffix). The app converts to `America/Chicago` (Central Time). Ensure your test scenarios account for the 5-6 hour offset. + +## Extending Tests + +To add a new test case: + +1. Add test configuration to `$testCases` array in `run_integration_tests.ps1` +2. Define mock ESPN API event data +3. Set `MockTime` for deterministic countdown +4. Run with `-UpdateGolden` to create reference image +5. Document expected behavior + +## CI/CD Integration + +```yaml +# Example GitHub Actions +- name: Run Integration Tests + run: | + cd apps/homegame/tests + pwsh -File run_integration_tests.ps1 +``` + +Exit codes: +- `0` = All tests passed +- `1` = One or more tests failed diff --git a/apps/homegame/tests/golden_images/countdown_away.webp b/apps/homegame/tests/golden_images/countdown_away.webp new file mode 100644 index 000000000..e48991ff1 Binary files /dev/null and b/apps/homegame/tests/golden_images/countdown_away.webp differ diff --git a/apps/homegame/tests/golden_images/countdown_home.webp b/apps/homegame/tests/golden_images/countdown_home.webp new file mode 100644 index 000000000..6f876eab2 Binary files /dev/null and b/apps/homegame/tests/golden_images/countdown_home.webp differ diff --git a/apps/homegame/tests/golden_images/future.webp b/apps/homegame/tests/golden_images/future.webp new file mode 100644 index 000000000..9bf055ffc Binary files /dev/null and b/apps/homegame/tests/golden_images/future.webp differ diff --git a/apps/homegame/tests/golden_images/in_progress.webp b/apps/homegame/tests/golden_images/in_progress.webp new file mode 100644 index 000000000..638aadacd Binary files /dev/null and b/apps/homegame/tests/golden_images/in_progress.webp differ diff --git a/apps/homegame/tests/run_integration_tests.ps1 b/apps/homegame/tests/run_integration_tests.ps1 new file mode 100644 index 000000000..201ccc985 --- /dev/null +++ b/apps/homegame/tests/run_integration_tests.ps1 @@ -0,0 +1,371 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Integration test runner for HomeGame app - tests ACTUAL production code + +.DESCRIPTION + This script tests the production homegame.star by injecting mock data via config. + No code duplication - tests the real app with deterministic mock data. + +.PARAMETER OutputDir + Directory to store test output images (default: ./test_output) + +.PARAMETER GoldenDir + Directory containing golden reference images (default: ./golden_images) + +.PARAMETER UpdateGolden + Update golden reference images instead of comparing + +.PARAMETER KeepImages + Keep generated test images after test completes + +.EXAMPLE + .\run_integration_tests.ps1 + .\run_integration_tests.ps1 -UpdateGolden # Update reference images + .\run_integration_tests.ps1 -KeepImages # Keep test outputs +#> + +param( + [string]$OutputDir = "test_output", + [string]$GoldenDir = "golden_images", + [switch]$UpdateGolden, + [switch]$KeepImages +) + +# Test configurations +$testCases = @( + @{ + Name = "future" + Description = "Future game (not game day)" + Expected = "Date and time on separate lines" + Event = @{ + date = "2025-10-19T23:00Z" + competitions = @( + @{ + status = @{ + type = @{ name = "scheduled" } + } + competitors = @( + @{ + homeAway = "home" + team = @{ + id = "245" + name = "Texas A&M Aggies" + abbreviation = "A&M" + } + }, + @{ + homeAway = "away" + team = @{ + id = "333" + name = "Louisiana State Tigers" + abbreviation = "LSU" + } + } + ) + } + ) + } + MockTime = "2025-10-11T20:30:00Z" # Oct 11, 3:30 PM Central + }, + @{ + Name = "countdown_home" + Description = "Game day countdown (HOME - RED background)" + Expected = "Countdown timer and RED HOME indicator" + Event = @{ + date = "2025-10-11T23:00Z" + competitions = @( + @{ + status = @{ + type = @{ name = "scheduled" } + } + competitors = @( + @{ + homeAway = "home" + team = @{ + id = "245" + name = "Texas A&M Aggies" + abbreviation = "A&M" + } + }, + @{ + homeAway = "away" + team = @{ + id = "2" + name = "Alabama Crimson Tide" + abbreviation = "ALA" + } + } + ) + } + ) + } + MockTime = "2025-10-11T20:30:00Z" # 2.5 hours before kickoff + }, + @{ + Name = "countdown_away" + Description = "Game day countdown (AWAY - GREEN background)" + Expected = "Countdown timer and GREEN AWAY indicator" + Event = @{ + date = "2025-10-11T22:30Z" + competitions = @( + @{ + status = @{ + type = @{ name = "scheduled" } + } + competitors = @( + @{ + homeAway = "home" + team = @{ + id = "57" + name = "Florida Gators" + abbreviation = "FLA" + } + }, + @{ + homeAway = "away" + team = @{ + id = "245" + name = "Texas A&M Aggies" + abbreviation = "A&M" + } + } + ) + } + ) + } + MockTime = "2025-10-11T20:30:00Z" # 2 hours before kickoff + }, + @{ + Name = "in_progress" + Description = "Game in progress" + Expected = "Scores on sides, Q3 in center" + Event = @{ + date = "2025-10-11T22:00Z" + competitions = @( + @{ + status = @{ + type = @{ name = "in"; detail = "In Progress" } + period = 3 + } + competitors = @( + @{ + homeAway = "home" + team = @{ + id = "245" + name = "Texas A&M Aggies" + abbreviation = "A&M" + } + score = "21" + }, + @{ + homeAway = "away" + team = @{ + id = "228" + name = "Texas Longhorns" + abbreviation = "TEX" + } + score = "17" + } + ) + } + ) + } + MockTime = "2025-10-11T23:30:00Z" # After kickoff + } +) + +# Colors for output +$ColorSuccess = "Green" +$ColorError = "Red" +$ColorInfo = "Cyan" +$ColorWarning = "Yellow" + +# Test results +$totalTests = $testCases.Count +$passedTests = 0 +$failedTests = 0 +$results = @() + +Write-Host "`n========================================" -ForegroundColor $ColorInfo +Write-Host " HomeGame - Integration Test Suite" -ForegroundColor $ColorInfo +Write-Host " (Testing ACTUAL Production Code)" -ForegroundColor $ColorInfo +Write-Host "========================================`n" -ForegroundColor $ColorInfo + +# Check if pixlet is available +Write-Host "[INFO] Checking for pixlet..." -ForegroundColor $ColorInfo +$pixletPath = Get-Command pixlet -ErrorAction SilentlyContinue +if (-not $pixletPath) { + Write-Host "[ERROR] pixlet not found in PATH" -ForegroundColor $ColorError + exit 1 +} +Write-Host "[OK] Found pixlet`n" -ForegroundColor $ColorSuccess + +# Create directories +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} +if (-not (Test-Path $GoldenDir) -and -not $UpdateGolden) { + Write-Host "[WARN] Golden image directory not found: $GoldenDir" -ForegroundColor $ColorWarning + Write-Host "[INFO] Run with -UpdateGolden to create reference images`n" -ForegroundColor $ColorInfo +} + +# Get production app path +$appPath = Join-Path (Split-Path -Parent $PSScriptRoot) "homegame.star" +if (-not (Test-Path $appPath)) { + Write-Host "[ERROR] Production app not found: $appPath" -ForegroundColor $ColorError + exit 1 +} + +Write-Host "[INFO] Testing production app: $appPath`n" -ForegroundColor $ColorInfo + +# Run tests +foreach ($test in $testCases) { + $testName = $test.Name + $outputFile = Join-Path $OutputDir "$testName.webp" + $goldenFile = Join-Path $GoldenDir "$testName.webp" + + Write-Host "----------------------------------------" -ForegroundColor $ColorInfo + Write-Host "TEST: $testName" -ForegroundColor $ColorInfo + Write-Host " Description: $($test.Description)" -ForegroundColor Gray + Write-Host " Expected: $($test.Expected)" -ForegroundColor Gray + Write-Host "" + + # Convert event to JSON (compact, no formatting) + $eventJson = ($test.Event | ConvertTo-Json -Depth 10 -Compress) + + # Encode JSON as base64 to avoid command-line escaping issues + $eventJsonBytes = [System.Text.Encoding]::UTF8.GetBytes($eventJson) + $eventJsonB64 = [Convert]::ToBase64String($eventJsonBytes) + + # Build pixlet command with injected config + Write-Host " [RUNNING] pixlet render (with injected test data)..." -NoNewline + + try { + # Use named parameters for clarity + $output = & pixlet render $appPath ` + "_test_event_b64=$eventJsonB64" ` + "_test_time=$($test.MockTime)" ` + "team_id=245" ` + "timezone=America/Chicago" ` + -o $outputFile ` + 2>&1 + + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0 -and (Test-Path $outputFile)) { + $fileSize = (Get-Item $outputFile).Length + + if ($fileSize -gt 0) { + # Compare with golden image + $imageMatch = $true + if ((Test-Path $goldenFile) -and -not $UpdateGolden) { + $goldenHash = (Get-FileHash $goldenFile -Algorithm MD5).Hash + $actualHash = (Get-FileHash $outputFile -Algorithm MD5).Hash + + if ($goldenHash -eq $actualHash) { + Write-Host " PASS (Exact Match)" -ForegroundColor $ColorSuccess + $passedTests++ + $results += @{ + Test = $testName + Status = "PASS" + Output = $outputFile + Match = "Exact" + } + } else { + Write-Host " FAIL (Visual Regression)" -ForegroundColor $ColorError + Write-Host " [ERROR] Output differs from golden image" -ForegroundColor $ColorError + Write-Host " [INFO] Golden: $goldenFile" -ForegroundColor Gray + Write-Host " [INFO] Actual: $outputFile" -ForegroundColor Gray + $failedTests++ + $imageMatch = $false + $results += @{ + Test = $testName + Status = "FAIL" + Error = "Visual regression detected" + } + } + } elseif ($UpdateGolden) { + # Copy to golden directory + Copy-Item $outputFile $goldenFile -Force + Write-Host " GOLDEN UPDATED" -ForegroundColor $ColorWarning + Write-Host " [INFO] Reference image saved: $goldenFile" -ForegroundColor Gray + $passedTests++ + } else { + # No golden image exists + Write-Host " PASS (No Golden)" -ForegroundColor $ColorWarning + Write-Host " [WARN] No golden image to compare. Run with -UpdateGolden to create." -ForegroundColor $ColorWarning + $passedTests++ + $results += @{ + Test = $testName + Status = "PASS" + Output = $outputFile + Match = "N/A" + } + } + } else { + Write-Host " FAIL (Empty)" -ForegroundColor $ColorError + $failedTests++ + } + } else { + Write-Host " FAIL" -ForegroundColor $ColorError + Write-Host " [ERROR] Render failed: $output" -ForegroundColor $ColorError + $failedTests++ + $results += @{ + Test = $testName + Status = "FAIL" + Error = "Render failed: $output" + } + } + } catch { + Write-Host " FAIL" -ForegroundColor $ColorError + Write-Host " [ERROR] Exception: $_" -ForegroundColor $ColorError + $failedTests++ + } + + Write-Host "" +} + +# Summary +Write-Host "`n========================================" -ForegroundColor $ColorInfo +Write-Host " TEST SUMMARY" -ForegroundColor $ColorInfo +Write-Host "========================================" -ForegroundColor $ColorInfo +Write-Host "Total Tests: $totalTests" -ForegroundColor Gray +Write-Host "Passed: $passedTests" -ForegroundColor $ColorSuccess +Write-Host "Failed: $failedTests" -ForegroundColor $(if ($failedTests -gt 0) { $ColorError } else { $ColorSuccess }) +Write-Host "" + +foreach ($result in $results) { + $status = $result.Status + $statusColor = if ($status -eq "PASS") { $ColorSuccess } else { $ColorError } + $matchInfo = if ($result.Match) { " ($($result.Match))" } else { "" } + + Write-Host "[$status] $($result.Test)$matchInfo" -ForegroundColor $statusColor + if ($result.Error) { + Write-Host " Error: $($result.Error)" -ForegroundColor $ColorError + } +} + +Write-Host "`n========================================`n" -ForegroundColor $ColorInfo + +# Final status +if ($passedTests -eq $totalTests) { + Write-Host "[SUCCESS] All tests passed!" -ForegroundColor $ColorSuccess + if ($UpdateGolden) { + Write-Host "`nGolden reference images updated in: $GoldenDir" -ForegroundColor $ColorInfo + } else { + Write-Host "`nAll outputs match golden reference images." -ForegroundColor $ColorSuccess + } +} else { + Write-Host "[FAILURE] Some tests failed." -ForegroundColor $ColorError +} + +# Cleanup +if (-not $KeepImages -and -not $UpdateGolden) { + Write-Host "`n[INFO] Cleaning up test images..." -ForegroundColor $ColorInfo + Remove-Item $OutputDir -Recurse -Force -ErrorAction SilentlyContinue +} else { + Write-Host "`n[INFO] Test images preserved in: $OutputDir" -ForegroundColor $ColorInfo +} + +exit $(if ($failedTests -eq 0) { 0 } else { 1 }) diff --git a/apps/homegame/todo.md b/apps/homegame/todo.md new file mode 100644 index 000000000..f4d4283d5 --- /dev/null +++ b/apps/homegame/todo.md @@ -0,0 +1,386 @@ +# HomeGame Improvement Plan +## Analysis: NCAAF Scores vs HomeGame + +Based on review of the official NCAAF Scores app source code: +https://raw.githubusercontent.com/tidbyt/community/refs/heads/main/apps/ncaafscores/ncaaf_scores.star + +--- + +## 🔍 Key Findings from NCAAF Scores App + +### ✅ **What They Do Well (We Should Adopt)** + +#### 1. **Defensive Color/Logo Handling** +```python +homeColorCheck = competition["competitors"][0]["team"].get("color", "NO") +if homeColorCheck == "NO": + homePrimaryColor = "000000" +else: + homePrimaryColor = competition["competitors"][0]["team"]["color"] +``` +- **Issue**: They check if color/logo exists before using it +- **Our app**: Could crash if ESPN doesn't provide team colors +- **Impact**: HIGH - Prevents crashes + +#### 2. **Alternative Color/Logo Dictionaries** +- Maintain hardcoded mappings for teams with bad/missing colors +- Example: `"SYR": "#000E54"`, `"LSU": "#461D7C"` +- **Our app**: Relies 100% on ESPN API data +- **Impact**: MEDIUM - Better visual quality + +#### 3. **Text Shortening Dictionary** +```python +SHORTENED_WORDS = { + " PM": "P", + " AM": "A", + "Postponed": "PPD", + "Overtime": "OT", + "1st Quarter": "Q1", + "2nd Quarter": "Q2", + "3rd Quarter": "Q3", + "4th Quarter": "Q4" +} +``` +- Automatically shortens common phrases +- **Our app**: Shows full text which may not fit on display +- **Impact**: MEDIUM - Better space utilization + +#### 4. **Configurable Cache TTL** +```python +CACHE_TTL_SECONDS = 60 +``` +- Uses named constant instead of magic number +- **Our app**: Has magic number `300` (5 minutes) hardcoded +- **Impact**: LOW - Better code maintainability + +#### 5. **Date Range API Query** +```python +datePast = now - time.parse_duration("%dh" % 1 * 24) +dateFuture = now + time.parse_duration("%dh" % 6 * 24) +``` +- Queries past 1 day + future 6 days to catch recent/upcoming games +- **Our app**: Only queries single team endpoint (may miss recent games) +- **Impact**: LOW - We only show next game (by design) + +#### 6. **Null Safety Patterns** +- Extensive use of `.get()` with fallbacks +- Validates values after retrieval +- **Our app**: Uses `.get()` but doesn't validate values +- **Impact**: HIGH - Prevents crashes + +#### 7. **Render Animation for Multiple Games** +```python +return render.Root( + delay = int(rotationSpeed) * 1000, + show_full_animation = True, + child = render.Animation(children = renderCategory) +) +``` +- Can display multiple games in rotation +- **Our app**: Single game only (as designed, but could offer option) +- **Impact**: LOW - Not our core feature + +--- + +### ⚠️ **What They Do Poorly (We Should Avoid)** + +#### 1. **Massive Hardcoded Data** +- 100+ lines of ALT_COLOR dictionary +- 100+ lines of ALT_LOGO dictionary +- 1000+ lines of team options in schema +- **Our app**: Minimal hardcoded data ✅ +- **Assessment**: We're better here - maintainability nightmare + +#### 2. **No Test Suite** +- No automated tests visible in their codebase +- **Our app**: Full integration test suite with golden images ✅ +- **Assessment**: We're WAY ahead here + +#### 3. **Complex Configuration** +- 8+ configuration options (overwhelming for users) +- Display type, pregame display, rotation speed, etc. +- **Our app**: Simple, focused configuration ✅ +- **Assessment**: We have better UX + +#### 4. **Multiple Display Modes** +- 6 different display modes to maintain +- "colors", "logos", "horizontal", "stadium", "retro" +- **Our app**: Single, focused display ✅ +- **Assessment**: We have better focus + +--- + +## 🎯 **Recommended Improvements for HomeGame** + +### Priority 1: Defensive Programming ⭐⭐⭐ (High Impact, Low Effort) + +**Problems to solve:** +- App could crash if ESPN API returns unexpected data +- No handling of missing scores, team names, colors +- Period numbers not validated + +**Solutions:** +1. Add defensive checks for missing API data +2. Add fallback values for team names, colors, scores +3. Validate period numbers are in expected range +4. Handle malformed ESPN responses gracefully + +**Code locations:** +- `parse_game_event()` - lines 171-348 +- `main()` - lines 61-87 + +**Example improvements:** +```python +# Current (vulnerable) +our_score = game_data.get("our_score", 0) + +# Improved (defensive) +our_score = game_data.get("our_score") +if our_score is None or not isinstance(our_score, (int, str)): + our_score = 0 +else: + our_score = int(our_score) +``` + +**Time estimate**: 30-45 minutes + +--- + +### Priority 2: Error Handling ⭐⭐ (High Impact, Medium Effort) + +**Problems to solve:** +- No try-catch around API calls +- Network failures cause crashes +- No timeout handling +- Users see cryptic errors + +**Solutions:** +1. Wrap API calls in try-catch blocks +2. Display friendly error messages instead of crashing +3. Add timeout handling for slow API responses +4. Log errors for debugging + +**Code locations:** +- `get_next_game()` - lines 89-169 +- API call at line ~115 + +**Example improvements:** +```python +try: + response = http.get(url, ttl_seconds=300) + if response.status_code != 200: + return None + data = response.json() +except Exception as e: + print("API Error:", str(e)) + return None +``` + +**Time estimate**: 45-60 minutes + +--- + +### Priority 3: Text Optimization ⭐ (Medium Impact, Low Effort) + +**Problems to solve:** +- Long team names may overflow display +- Period text could be shorter ("Q3" vs "3rd Quarter") +- Magic numbers throughout code + +**Solutions:** +1. Add text shortening for long team names +2. Abbreviate overtime periods (OT, 2OT, 3OT) - already done! ✅ +3. Use constants for magic numbers (cache TTL, dimensions) + +**Code locations:** +- Text rendering in `render_game_display()` - lines 377-555 +- Constants at top of file + +**Example improvements:** +```python +# Add constants +CACHE_TTL_SECONDS = 300 # 5 minutes +DISPLAY_WIDTH = 64 +DISPLAY_HEIGHT = 32 +MAX_TEAM_NAME_LENGTH = 6 + +# Shorten team names +def shorten_team_name(name, max_length=6): + if len(name) <= max_length: + return name + return name[:max_length-1] + "." +``` + +**Time estimate**: 20-30 minutes + +--- + +### Priority 4: Configuration ⭐ (Low Impact, Low Effort) + +**Problems to solve:** +- Cache TTL is hardcoded +- No option to customize display +- Limited user control + +**Solutions:** +1. Make cache TTL configurable (via schema) +2. Add option to show multiple upcoming games (future feature) +3. Make colors/fonts user-configurable (future feature) + +**Code locations:** +- `get_schema()` - lines 584-596 +- Cache calls throughout + +**Example improvements:** +```python +def get_schema(): + return schema.Schema( + version = "1", + fields = [ + schema.Text( + id = "team_id", + name = "Team ID", + desc = "ESPN Team ID", + icon = "football", + ), + schema.Dropdown( + id = "cache_ttl", + name = "Refresh Rate", + desc = "How often to fetch new data", + icon = "clock", + default = "300", + options = [ + schema.Option(display = "1 minute", value = "60"), + schema.Option(display = "5 minutes", value = "300"), + schema.Option(display = "10 minutes", value = "600"), + ], + ), + ], + ) +``` + +**Time estimate**: 15-20 minutes + +--- + +## 📋 **Proposed Implementation Phases** + +### Phase 1: Defensive Programming (30-45 min) +**Goal**: Prevent crashes from bad API data + +- [ ] Add null checks for scores, team names, period +- [ ] Add type validation (ensure scores are numbers) +- [ ] Add fallback values for missing data +- [ ] Extract magic numbers to constants +- [ ] Test with malformed mock data + +**Test strategy**: Update test data to include edge cases + +--- + +### Phase 2: Error Handling (45-60 min) +**Goal**: Graceful degradation on errors + +- [ ] Wrap API calls in try-catch +- [ ] Add HTTP status code checks +- [ ] Add timeout handling (already exists via http.get ttl) +- [ ] Enhance error rendering function +- [ ] Add friendly error messages +- [ ] Test with network failures + +**Test strategy**: Create test case that simulates API failure + +--- + +### Phase 3: Text Optimization (20-30 min) +**Goal**: Better space utilization + +- [ ] Create constants for dimensions +- [ ] Create constants for cache TTL +- [ ] Add team name shortening function (if needed) +- [ ] Review all text for opportunities to abbreviate +- [ ] Test with long team names + +**Test strategy**: Test with longest possible team names + +--- + +### Phase 4: Documentation (15-20 min) +**Goal**: Document defensive patterns + +- [ ] Document error handling approach in DEVELOPMENT.md +- [ ] Update REQUIREMENTS.md with defensive patterns +- [ ] Add error handling examples +- [ ] Document constants and their purposes +- [ ] Add troubleshooting section + +--- + +## 🚫 **What NOT to Change** + +**Our Strengths - Keep These!** + +1. ✅ **Keep single-game focus** (core feature - don't dilute) +2. ✅ **Keep simple configuration** (better UX than NCAAF) +3. ✅ **Keep test suite** (we're WAY ahead of NCAAF here!) +4. ✅ **Keep clean architecture** (their code is bloated with 100+ line dictionaries) +5. ✅ **Keep focused display** (1 mode is better than 6 modes to maintain) +6. ✅ **Keep minimal hardcoded data** (easier to maintain) + +--- + +## 📊 **Comparison Summary** + +| Feature | NCAAF Scores | HomeGame | Winner | +|---------|--------------|----------|--------| +| **Defensive Programming** | ✅ Excellent | ⚠️ Basic | NCAAF | +| **Error Handling** | ⚠️ Basic | ⚠️ Basic | Tie | +| **Test Suite** | ❌ None | ✅ Full Integration | HomeGame | +| **Code Complexity** | ⚠️ Very High | ✅ Clean | HomeGame | +| **Configuration** | ⚠️ Overwhelming | ✅ Simple | HomeGame | +| **Display Modes** | ⚠️ 6 modes | ✅ 1 focused | HomeGame | +| **Maintainability** | ❌ Poor | ✅ Excellent | HomeGame | +| **User Experience** | ⚠️ Complex | ✅ Simple | HomeGame | + +--- + +## 🏆 **Bottom Line** + +**NCAAF Scores Strengths**: +- Defensive programming (null checks, fallbacks) +- Extensive team color/logo mappings + +**HomeGame Strengths**: +- Clean, maintainable code +- Comprehensive test suite with visual regression +- Focused, simple UX +- Modern development practices (linting, pre-commit hooks) + +**Best Strategy**: +Adopt NCAAF's **defensive patterns** WITHOUT adopting their **complexity and bloat**. + +--- + +## 🎯 **Next Steps** + +1. **Review this plan** - Discuss priorities +2. **Choose phase to start** - Recommend Phase 1 (defensive programming) +3. **Implement incrementally** - One phase at a time +4. **Test thoroughly** - Run integration tests after each change +5. **Update golden images** - As visual output changes + +--- + +## 📝 **Notes** + +- All time estimates are conservative +- Each phase can be done independently +- Tests must pass before moving to next phase +- Keep git commits small and focused +- Update documentation as you go + +--- + +**Created**: 2025-10-12 +**Source**: Review of [NCAAF Scores](https://raw.githubusercontent.com/tidbyt/community/refs/heads/main/apps/ncaafscores/ncaaf_scores.star) +**Status**: Planning phase - not implemented yet