Casual Fan Sports Report
Picker hidden — your selections are still active.
'worldcup', 'type' => 'worldcup', 'label' => 'Fetching USA World Cup schedule', 'url' => 'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?limit=100&dates=20260601-20260731', ]; // 2. Every team's schedule, grouped by city (order matches config) foreach ($CITIES as $cityKey => $cityData) { foreach ($cityData['teams'] as $i => $team) { $sportInfo = $SPORT_LABELS[$team['league']] ?? null; if (!$sportInfo) { continue; } $steps[] = [ 'key' => "{$cityKey}_{$i}", 'type' => 'team', 'label' => "Fetching {$cityData['label']}: {$team['name']} (" . strtoupper($team['league']) . ') schedule', // College teams need ESPN's numeric team id in the URL (the // abbreviation-based schedule route is unreliable for them); // 'abbr' is still what game-matching compares against. Pro // teams have no 'id' and keep using their abbreviation. 'url' => schedule_url($sportInfo['sport'], $sportInfo['slug'] ?? $team['league'], $team['id'] ?? $team['abbr']), 'city_key' => $cityKey, 'team_index' => $i, ]; } } // 3. Major events foreach ($MAJOR_EVENTS as $i => $evt) { $datesRange = major_event_dates_range($evt['window_months'], $evt['spans_new_year']); $steps[] = [ 'key' => "major_{$i}", 'type' => 'major_event', 'label' => "Fetching {$evt['label']}", 'url' => scoreboard_url($evt['sport'], $evt['league'], $datesRange, $evt['scoreboard_params'] ?? []), 'event_index' => $i, ]; } return $steps; } /** * Build the abbreviated step list for Level 2 live updates. */ function build_live_step_list(): array { return [ ['key' => 'live_nfl', 'label' => 'NFL Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?limit=100'], ['key' => 'live_mlb', 'label' => 'MLB Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard?limit=100'], ['key' => 'live_nhl', 'label' => 'NHL Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard?limit=100'], ['key' => 'live_nba', 'label' => 'NBA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard?limit=100'], ['key' => 'live_wnba', 'label' => 'WNBA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard?limit=100'], // College scoreboards are large and default to a featured subset, so // groups=50 (all D-I) plus a high limit ensures a tracked school's // game is present. These only matter in-season (Nov–Apr). ['key' => 'live_ncaam', 'label' => 'NCAAM Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard?groups=50&limit=400'], ['key' => 'live_ncaaw', 'label' => 'NCAAW Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard?groups=50&limit=400'], ['key' => 'live_fifa', 'label' => 'FIFA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?limit=100'], ]; } function fetch_step(array $step, ?callable $log = null): ?array { if ($log) { $log("fetch_step key={$step['key']} url={$step['url']}"); } $result = fetch_json_multi([$step['key'] => $step['url']], $log); return $result[$step['key']] ?? null; } function aggregate_database(array $rawByKey, array $CITIES, array $MAJOR_EVENTS, array $SPORT_LABELS): array { $database = []; $allLeaguesData = []; $oneMonthAgo = strtotime('-1 month'); // Initialize the master "All Cities" array structure upfront foreach ($CITIES as $cityData) { foreach ($cityData['teams'] as $team) { $leagueKey = strtoupper($team['league']); if (!isset($allLeaguesData[$leagueKey])) { $allLeaguesData[$leagueKey] = [ 'latest_timestamp' => 0, 'live' => [], 'games' => [], 'upcoming' => [] ]; } } } // --- USA World Cup --- $wcData = $rawByKey['worldcup'] ?? null; $usaEvents = []; if ($wcData && !empty($wcData['events'])) { foreach ($wcData['events'] as $event) { $competition = $event['competitions'][0] ?? null; if ($competition && !empty($competition['competitors'])) { foreach ($competition['competitors'] as $comp) { if (strtolower($comp['team']['abbreviation'] ?? '') === 'usa') { $usaEvents[] = $event; break; } } } } } $usaData = ['events' => $usaEvents]; $fifaGames = []; $fifaUpcoming = []; $fifaLatestTimestamp = 0; $games = last_completed_games($usaData, 2); foreach ($games as $event) { $gameTimestamp = isset($event['date']) ? strtotime($event['date']) : 0; if ($gameTimestamp < $oneMonthAgo) continue; $result = summarize_game($event, 'usa'); if (!$result) continue; $altNote = $event['competitions'][0]['altGameNote'] ?? ''; $stageLabel = 'World Cup'; if ($altNote) { $stageLabel = str_replace('FIFA World Cup, ', 'World Cup ', $altNote); $stageLabel = str_replace('FIFA ', '', $stageLabel); } $gameDateStr = date('Y-m-d', $gameTimestamp); $todayStr = date('Y-m-d'); $yesterdayStr = date('Y-m-d', strtotime('yesterday')); $twoDaysAgoStr = date('Y-m-d', strtotime('-2 days')); if ($gameDateStr === $todayStr) { $relativeDate = 'Today'; } elseif ($gameDateStr === $yesterdayStr) { $relativeDate = 'Yesterday'; } elseif ($gameDateStr === $twoDaysAgoStr) { $relativeDate = '2 days ago'; } else { $relativeDate = date('M j', $gameTimestamp); } $outcomeLabels = ['win' => 'Won', 'loss' => 'Lost', 'tie' => 'Tied', 'postponed' => 'Postponed']; $outcome = $outcomeLabels[$result['status']] ?? 'Final'; $vsAt = $result['is_home'] ? 'vs' : '@'; $fifaGames[] = [ 'timestamp' => $gameTimestamp, 'team_name' => 'USA', 'label' => $stageLabel, 'outcome' => $outcome, 'vsAt' => $vsAt, 'opponent' => $result['opponent'], 'team_score' => $result['team_score'], 'opp_score' => $result['opp_score'], 'date_str' => $relativeDate, 'date_raw' => $result['date_raw'], 'game_id' => $result['game_id'] ?? null, ]; if ($gameTimestamp > $fifaLatestTimestamp) $fifaLatestTimestamp = $gameTimestamp; } $upcomingResult = get_upcoming_game($usaData, 'usa'); if ($upcomingResult) { $altNote = ''; foreach ($usaData['events'] as $evt) { if (isset($evt['date']) && $evt['date'] === $upcomingResult['date_raw']) { $altNote = $evt['competitions'][0]['altGameNote'] ?? ''; break; } } $stageLabel = 'World Cup'; if ($altNote) { $stageLabel = str_replace('FIFA World Cup, ', 'World Cup ', $altNote); $stageLabel = str_replace('FIFA ', '', $stageLabel); } $upcomingTimestamp = strtotime($upcomingResult['date_raw']); $gameDateStr = date('Y-m-d', $upcomingTimestamp); $todayStr = date('Y-m-d'); $tomorrowStr = date('Y-m-d', strtotime('tomorrow')); if ($gameDateStr === $todayStr) { $relativeUpcoming = 'Today, ' . date('g:i A', $upcomingTimestamp); } elseif ($gameDateStr === $tomorrowStr) { $relativeUpcoming = 'Tomorrow, ' . date('g:i A', $upcomingTimestamp); } else { $relativeUpcoming = date('M j, g:i A', $upcomingTimestamp); } $fifaUpcoming[] = [ 'timestamp' => $upcomingTimestamp, 'team_name' => 'USA', 'label' => $stageLabel, 'vsAt' => $upcomingResult['is_home'] ? 'vs' : '@', 'opponent' => $upcomingResult['opponent'], 'date_str' => $relativeUpcoming, 'date_raw' => $upcomingResult['date_raw'], 'game_id' => $upcomingResult['game_id'] ?? null, ]; } // Process city by city foreach ($CITIES as $cityKey => $cityData) { $leaguesData = []; foreach ($cityData['teams'] as $i => $team) { $leagueKey = strtoupper($team['league']); if (!isset($leaguesData[$leagueKey])) { $leaguesData[$leagueKey] = [ 'latest_timestamp' => 0, 'live' => [], 'games' => [], 'upcoming' => [] ]; } } foreach ($cityData['teams'] as $i => $team) { $leagueKey = strtoupper($team['league']); $sportInfo = $SPORT_LABELS[$team['league']] ?? null; $requestKey = "{$cityKey}_{$i}"; if (!$sportInfo || empty($rawByKey[$requestKey])) continue; $rawSchedule = $rawByKey[$requestKey]; $games = last_completed_games($rawSchedule, 2); foreach ($games as $event) { $gameTimestamp = isset($event['date']) ? strtotime($event['date']) : 0; if ($gameTimestamp < $oneMonthAgo) continue; $result = summarize_game($event, $team['abbr']); if (!$result) continue; $gameDateStr = date('Y-m-d', $gameTimestamp); $todayStr = date('Y-m-d'); $yesterdayStr = date('Y-m-d', strtotime('yesterday')); $twoDaysAgoStr = date('Y-m-d', strtotime('-2 days')); if ($gameDateStr === $todayStr) $relativeDate = 'Today'; elseif ($gameDateStr === $yesterdayStr) $relativeDate = 'Yesterday'; elseif ($gameDateStr === $twoDaysAgoStr) $relativeDate = '2 days ago'; else $relativeDate = date('M j', $gameTimestamp); $outcomeLabels = ['win' => 'Won', 'loss' => 'Lost', 'tie' => 'Tied', 'postponed' => 'Postponed']; $outcome = $outcomeLabels[$result['status']] ?? 'Final'; $vsAt = $result['is_home'] ? 'vs' : '@'; $gameRecord = [ 'timestamp' => $gameTimestamp, 'team_name' => $team['name'], 'label' => $sportInfo['label'], 'outcome' => $outcome, 'vsAt' => $vsAt, 'opponent' => $result['opponent'], 'team_score' => $result['team_score'], 'opp_score' => $result['opp_score'], 'date_str' => $relativeDate, 'date_raw' => $result['date_raw'], 'game_id' => $result['game_id'] ?? null, ]; $leaguesData[$leagueKey]['games'][] = $gameRecord; $allLeaguesData[$leagueKey]['games'][] = $gameRecord; if ($gameTimestamp > $leaguesData[$leagueKey]['latest_timestamp']) { $leaguesData[$leagueKey]['latest_timestamp'] = $gameTimestamp; } if ($gameTimestamp > $allLeaguesData[$leagueKey]['latest_timestamp']) { $allLeaguesData[$leagueKey]['latest_timestamp'] = $gameTimestamp; } } $upcomingResult = get_upcoming_game($rawSchedule, $team['abbr']); if ($upcomingResult) { $upcomingTimestamp = strtotime($upcomingResult['date_raw']); $gameDateStr = date('Y-m-d', $upcomingTimestamp); $todayStr = date('Y-m-d'); $tomorrowStr = date('Y-m-d', strtotime('tomorrow')); if ($gameDateStr === $todayStr) { $relativeUpcoming = 'Today, ' . date('g:i A', $upcomingTimestamp); } elseif ($gameDateStr === $tomorrowStr) { $relativeUpcoming = 'Tomorrow, ' . date('g:i A', $upcomingTimestamp); } else { $relativeUpcoming = date('M j, g:i A', $upcomingTimestamp); } $upcomingRecord = [ 'timestamp' => $upcomingTimestamp, 'team_name' => $team['name'], 'label' => $sportInfo['label'], 'vsAt' => $upcomingResult['is_home'] ? 'vs' : '@', 'opponent' => $upcomingResult['opponent'], 'date_str' => $relativeUpcoming, 'date_raw' => $upcomingResult['date_raw'], 'game_id' => $upcomingResult['game_id'] ?? null, ]; $leaguesData[$leagueKey]['upcoming'][] = $upcomingRecord; $allLeaguesData[$leagueKey]['upcoming'][] = $upcomingRecord; } } if (!empty($fifaGames) || !empty($fifaUpcoming)) { $leaguesData['FIFA'] = [ 'latest_timestamp' => $fifaLatestTimestamp, 'live' => [], 'games' => $fifaGames, 'upcoming' => $fifaUpcoming ]; } uasort($leaguesData, function ($a, $b) { return $b['latest_timestamp'] <=> $a['latest_timestamp']; }); foreach ($leaguesData as &$data) { usort($data['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); usort($data['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; }); } unset($data); $database[$cityKey] = [ 'label' => $cityData['label'], 'is_all' => false, 'leagues' => $leaguesData ]; } if (!empty($fifaGames) || !empty($fifaUpcoming)) { $allLeaguesData['FIFA'] = [ 'latest_timestamp' => $fifaLatestTimestamp, 'live' => [], 'games' => $fifaGames, 'upcoming' => $fifaUpcoming ]; } uasort($allLeaguesData, function ($a, $b) { return $b['latest_timestamp'] <=> $a['latest_timestamp']; }); foreach ($allLeaguesData as &$data) { usort($data['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); usort($data['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; }); } unset($data); $database['all'] = [ 'label' => 'All Cities', 'is_all' => true, 'leagues' => $allLeaguesData ]; $majorEventsOut = []; foreach ($MAJOR_EVENTS as $i => $evt) { $scoreboardData = $rawByKey["major_{$i}"] ?? null; $championshipEvent = find_championship_game( $scoreboardData, $evt['keywords'], $evt['requires_postseason_flag'] ); if (!$championshipEvent) continue; $summary = summarize_championship_game($championshipEvent, $evt['label']); if (!$summary) continue; $eventTimestamp = isset($summary['date_raw']) ? strtotime($summary['date_raw']) : 0; if ($eventTimestamp < $oneMonthAgo) continue; $majorEventsOut[] = $summary; } usort($majorEventsOut, function ($a, $b) { return strtotime($b['date_raw'] ?? 'now') <=> strtotime($a['date_raw'] ?? 'now'); }); $database['_major_events'] = $majorEventsOut; $allTeamsOut = []; foreach ($CITIES as $cityKey => $cityData) { foreach ($cityData['teams'] as $team) { $allTeamsOut[] = [ 'name' => $team['name'], 'league' => strtoupper($team['league']), 'abbr' => $team['abbr'], 'city_key' => $cityKey, 'city_label'=> $cityData['label'], 'city' => $team['city'] ?? '', 'state' => $team['state'] ?? '', 'search' => $team['search'] ?? '', ]; } } if (!empty($fifaGames) || !empty($fifaUpcoming)) { $allTeamsOut[] = [ 'name' => 'USA', 'league' => 'FIFA', 'abbr' => 'usa', 'city_key' => 'all', 'city_label' => 'National', 'city' => '', 'state' => 'United States', 'search' => 'USMNT', ]; } $database['_all_teams'] = $allTeamsOut; return $database; } /** * Mutates a provided database in-place, adding live games extracted from scoreboards * while removing those same games from the upcoming array to prevent duplication. */ function apply_live_scoreboards(array &$database, array $liveRawByKey, array $CITIES, ?callable $log = null): void { // Which league each build_live_step_list() key represents. We need this // because ESPN abbreviations are NOT unique across leagues — e.g. "phi" // is reused by the Eagles (NFL), 76ers (NBA), Phillies (MLB), and Flyers // (NHL). A single global abbr->team lookup would let later teams silently // overwrite earlier ones sharing the same city abbreviation. Since each // scoreboard fetch is already scoped to one league, we build a lookup // PER LEAGUE instead, so "phi" in the MLB scoreboard only ever resolves // against MLB teams. $keyToLeague = [ 'live_nfl' => 'NFL', 'live_mlb' => 'MLB', 'live_nhl' => 'NHL', 'live_nba' => 'NBA', 'live_wnba' => 'WNBA', 'live_ncaam' => 'NCAAM', 'live_ncaaw' => 'NCAAW', 'live_fifa' => 'FIFA', ]; $teamLookupByLeague = []; foreach ($CITIES as $cKey => $cData) { foreach ($cData['teams'] as $team) { $leagueKey = strtoupper($team['league']); $teamLookupByLeague[$leagueKey][strtolower($team['abbr'])] = [ 'city_key' => $cKey, 'league' => $leagueKey, 'name' => $team['name'] ]; } } // FIFA isn't in $CITIES (it's the national team, not a city team), and we // only ever care about the USA's match, so this scoping also naturally // filters the FIFA scoreboard down to just USA's game. $teamLookupByLeague['FIFA']['usa'] = ['city_key' => 'all', 'league' => 'FIFA', 'name' => 'USA']; foreach ($liveRawByKey as $key => $scoreboardData) { if (!$scoreboardData || empty($scoreboardData['events'])) { if ($log) $log("apply key=$key: no scoreboard data or no events"); continue; } $league = $keyToLeague[$key] ?? null; if (!$league || empty($teamLookupByLeague[$league])) { if ($log) $log("apply key=$key: unknown league mapping — skipping"); continue; } $teamLookup = $teamLookupByLeague[$league]; $eventTotal = count($scoreboardData['events']); $inCount = 0; $matched = 0; $unmatched = []; foreach ($scoreboardData['events'] as $event) { $state = $event['status']['type']['state'] ?? ''; if ($state !== 'in') continue; // only process live games $inCount++; $competition = $event['competitions'][0] ?? null; if (!$competition || empty($competition['competitors'])) continue; $comps = $competition['competitors']; if (count($comps) < 2) continue; // Evaluate for each competitor in our tracked list foreach ($comps as $idx => $competitor) { $abbr = strtolower($competitor['team']['abbreviation'] ?? ''); if (!isset($teamLookup[$abbr])) { $unmatched[$abbr] = true; continue; } $matched++; $info = $teamLookup[$abbr]; $opponent = $idx === 0 ? $comps[1] : $comps[0]; $teamScore = (int) extract_score($competitor['score'] ?? 0); $oppScore = (int) extract_score($opponent['score'] ?? 0); if ($teamScore > $oppScore) $liveStatus = 'Winning'; elseif ($teamScore < $oppScore) $liveStatus = 'Losing'; else $liveStatus = 'Tied'; $progress = $competition['status']['type']['detail'] ?? 'In Progress'; $record = [ 'timestamp' => strtotime($event['date']), 'team_name' => $info['name'], 'label' => $info['league'], 'outcome' => 'live', 'live_status'=> $liveStatus, 'team_score' => $teamScore, 'opp_score' => $oppScore, 'vsAt' => ($competitor['homeAway'] ?? '') === 'home' ? 'vs' : '@', 'opponent' => $opponent['team']['displayName'] ?? ($opponent['team']['name'] ?? 'Opponent'), 'progress' => $progress, 'date_raw' => $event['date'], 'game_id' => $event['id'] ?? null, ]; $applyLiveToBucket = function(&$leagueBucket) use ($record) { if (!isset($leagueBucket['live'])) $leagueBucket['live'] = []; $leagueBucket['live'][] = $record; // Drop from upcoming if it's currently live if (isset($leagueBucket['upcoming'])) { foreach ($leagueBucket['upcoming'] as $k => $up) { if (strtolower($up['opponent']) === strtolower($record['opponent'])) { unset($leagueBucket['upcoming'][$k]); } } $leagueBucket['upcoming'] = array_values($leagueBucket['upcoming']); } }; // National teams (city_key === 'all', e.g. USA at the World // Cup) aren't tied to one region — aggregate_database() copies // their completed/upcoming games into EVERY city's FIFA bucket, // so a live national game must fan out the same way. City teams // write only to their own region. $isNational = ($info['city_key'] === 'all'); $wroteCityCount = 0; if ($isNational) { foreach (array_keys($database) as $dbKey) { if ($dbKey === 'all') continue; // 'all' handled separately below if (!isset($database[$dbKey]['leagues'][$info['league']])) continue; $applyLiveToBucket($database[$dbKey]['leagues'][$info['league']]); $wroteCityCount++; } } elseif (isset($database[$info['city_key']]['leagues'][$info['league']])) { $applyLiveToBucket($database[$info['city_key']]['leagues'][$info['league']]); $wroteCityCount = 1; } $wroteAll = false; if (isset($database['all']['leagues'][$info['league']])) { $applyLiveToBucket($database['all']['leagues'][$info['league']]); $wroteAll = true; } if ($log) { $log(sprintf( 'apply key=%s matched %s (%s) abbr=%s %s %d-%d vs %s [%s] -> %s region bucket(s): %d written, all bucket: %s', $key, $info['name'], $info['league'], $abbr, $liveStatus, $teamScore, $oppScore, $record['opponent'], $progress, $isNational ? 'national (all regions)' : $info['city_key'], $wroteCityCount, $wroteAll ? 'written' : 'MISSING BUCKET' )); } } } if ($log) { $log(sprintf( 'apply key=%s summary: league=%s events=%d live(in)=%d competitorsMatched=%d unmatchedAbbrs=[%s]', $key, $league, $eventTotal, $inCount, $matched, implode(',', array_keys($unmatched)) )); } } } function render_index_html(array $database, array $CITIES, string $timestamp): string { $jsonDatabase = json_encode($database, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); $updatedAtMs = time() * 1000; // build time, for the "last updated" hover tooltip $optionsHtml = ''; foreach ($CITIES as $key => $city) { $selected = ($key === 'chicago_wisconsin') ? ' selected' : ''; $optionsHtml .= ' ' . "\n"; } $optionsHtml .= ' ' . "\n"; $optionsHtml .= ' ' . "\n"; $indexTemplate = <<
Picker hidden — your selections are still active.