1493 lines · 61.6 KB
Raw Download
1
<?php
2
/**
3
 * builder-core.php
4
 *
5
 * Shared logic for turning raw ESPN API responses into the site's
6
 * $database array and the final index.php HTML.
7
 */
8
9
require_once __DIR__ . '/config-15.php';
10
require_once __DIR__ . '/api.php';
11
12
function build_step_list(array $CITIES, array $MAJOR_EVENTS, array $SPORT_LABELS): array
13
{
14
    $steps = [];
15
16
    // 1. USA World Cup
17
    $steps[] = [
18
        'key'   => 'worldcup',
19
        'type'  => 'worldcup',
20
        'label' => 'Fetching USA World Cup schedule',
21
        'url'   => 'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?limit=100&dates=20260601-20260731',
22
    ];
23
24
    // 2. Every team's schedule, grouped by city (order matches config)
25
    foreach ($CITIES as $cityKey => $cityData) {
26
        foreach ($cityData['teams'] as $i => $team) {
27
            $sportInfo = $SPORT_LABELS[$team['league']] ?? null;
28
            if (!$sportInfo) {
29
                continue;
30
            }
31
            $steps[] = [
32
                'key'        => "{$cityKey}_{$i}",
33
                'type'       => 'team',
34
                'label'      => "Fetching {$cityData['label']}: {$team['name']} (" . strtoupper($team['league']) . ') schedule',
35
                // College teams need ESPN's numeric team id in the URL (the
36
                // abbreviation-based schedule route is unreliable for them);
37
                // 'abbr' is still what game-matching compares against. Pro
38
                // teams have no 'id' and keep using their abbreviation.
39
                'url'        => schedule_url($sportInfo['sport'], $sportInfo['slug'] ?? $team['league'], $team['id'] ?? $team['abbr']),
40
                'city_key'   => $cityKey,
41
                'team_index' => $i,
42
            ];
43
        }
44
    }
45
46
    // 3. Major events
47
    foreach ($MAJOR_EVENTS as $i => $evt) {
48
        $datesRange = major_event_dates_range($evt['window_months'], $evt['spans_new_year']);
49
        $steps[] = [
50
            'key'         => "major_{$i}",
51
            'type'        => 'major_event',
52
            'label'       => "Fetching {$evt['label']}",
53
            'url'         => scoreboard_url($evt['sport'], $evt['league'], $datesRange, $evt['scoreboard_params'] ?? []),
54
            'event_index' => $i,
55
        ];
56
    }
57
58
    return $steps;
59
}
60
61
/**
62
 * Build the abbreviated step list for Level 2 live updates.
63
 */
64
function build_live_step_list(): array
65
{
66
    return [
67
        ['key' => 'live_nfl', 'label' => 'NFL Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?limit=100'],
68
        ['key' => 'live_mlb', 'label' => 'MLB Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard?limit=100'],
69
        ['key' => 'live_nhl', 'label' => 'NHL Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard?limit=100'],
70
        ['key' => 'live_nba', 'label' => 'NBA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard?limit=100'],
71
        ['key' => 'live_wnba', 'label' => 'WNBA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard?limit=100'],
72
        // College scoreboards are large and default to a featured subset, so
73
        // groups=50 (all D-I) plus a high limit ensures a tracked school's
74
        // game is present. These only matter in-season (Nov–Apr).
75
        ['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'],
76
        ['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'],
77
        ['key' => 'live_fifa', 'label' => 'FIFA Live', 'url' => 'http://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?limit=100'],
78
    ];
79
}
80
81
function fetch_step(array $step, ?callable $log = null): ?array
82
{
83
    if ($log) {
84
        $log("fetch_step key={$step['key']} url={$step['url']}");
85
    }
86
    $result = fetch_json_multi([$step['key'] => $step['url']], $log);
87
    return $result[$step['key']] ?? null;
88
}
89
90
function aggregate_database(array $rawByKey, array $CITIES, array $MAJOR_EVENTS, array $SPORT_LABELS): array
91
{
92
    $database = [];
93
    $allLeaguesData = [];
94
    $oneMonthAgo = strtotime('-1 month');
95
96
    // Initialize the master "All Cities" array structure upfront
97
    foreach ($CITIES as $cityData) {
98
        foreach ($cityData['teams'] as $team) {
99
            $leagueKey = strtoupper($team['league']);
100
            if (!isset($allLeaguesData[$leagueKey])) {
101
                $allLeaguesData[$leagueKey] = [
102
                    'latest_timestamp' => 0,
103
                    'live'             => [],
104
                    'games'            => [],
105
                    'upcoming'         => []
106
                ];
107
            }
108
        }
109
    }
110
111
    // --- USA World Cup ---
112
    $wcData = $rawByKey['worldcup'] ?? null;
113
    $usaEvents = [];
114
    if ($wcData && !empty($wcData['events'])) {
115
        foreach ($wcData['events'] as $event) {
116
            $competition = $event['competitions'][0] ?? null;
117
            if ($competition && !empty($competition['competitors'])) {
118
                foreach ($competition['competitors'] as $comp) {
119
                    if (strtolower($comp['team']['abbreviation'] ?? '') === 'usa') {
120
                        $usaEvents[] = $event;
121
                        break;
122
                    }
123
                }
124
            }
125
        }
126
    }
127
128
    $usaData = ['events' => $usaEvents];
129
    $fifaGames = [];
130
    $fifaUpcoming = [];
131
    $fifaLatestTimestamp = 0;
132
133
    $games = last_completed_games($usaData, 2);
134
    foreach ($games as $event) {
135
        $gameTimestamp = isset($event['date']) ? strtotime($event['date']) : 0;
136
        if ($gameTimestamp < $oneMonthAgo) continue;
137
138
        $result = summarize_game($event, 'usa');
139
        if (!$result) continue;
140
141
        $altNote = $event['competitions'][0]['altGameNote'] ?? '';
142
        $stageLabel = 'World Cup';
143
        if ($altNote) {
144
            $stageLabel = str_replace('FIFA World Cup, ', 'World Cup ', $altNote);
145
            $stageLabel = str_replace('FIFA ', '', $stageLabel);
146
        }
147
148
        $gameDateStr   = date('Y-m-d', $gameTimestamp);
149
        $todayStr      = date('Y-m-d');
150
        $yesterdayStr  = date('Y-m-d', strtotime('yesterday'));
151
        $twoDaysAgoStr = date('Y-m-d', strtotime('-2 days'));
152
153
        if ($gameDateStr === $todayStr) {
154
            $relativeDate = 'Today';
155
        } elseif ($gameDateStr === $yesterdayStr) {
156
            $relativeDate = 'Yesterday';
157
        } elseif ($gameDateStr === $twoDaysAgoStr) {
158
            $relativeDate = '2 days ago';
159
        } else {
160
            $relativeDate = date('M j', $gameTimestamp);
161
        }
162
163
        $outcomeLabels = ['win' => 'Won', 'loss' => 'Lost', 'tie' => 'Tied', 'postponed' => 'Postponed'];
164
        $outcome = $outcomeLabels[$result['status']] ?? 'Final';
165
        $vsAt    = $result['is_home'] ? 'vs' : '@';
166
167
        $fifaGames[] = [
168
            'timestamp'  => $gameTimestamp,
169
            'team_name'  => 'USA',
170
            'label'      => $stageLabel,
171
            'outcome'    => $outcome,
172
            'vsAt'       => $vsAt,
173
            'opponent'   => $result['opponent'],
174
            'team_score' => $result['team_score'],
175
            'opp_score'  => $result['opp_score'],
176
            'date_str'   => $relativeDate,
177
            'date_raw'   => $result['date_raw'],
178
            'game_id'    => $result['game_id'] ?? null,
179
        ];
180
        if ($gameTimestamp > $fifaLatestTimestamp) $fifaLatestTimestamp = $gameTimestamp;
181
    }
182
183
    $upcomingResult = get_upcoming_game($usaData, 'usa');
184
    if ($upcomingResult) {
185
        $altNote = '';
186
        foreach ($usaData['events'] as $evt) {
187
            if (isset($evt['date']) && $evt['date'] === $upcomingResult['date_raw']) {
188
                $altNote = $evt['competitions'][0]['altGameNote'] ?? '';
189
                break;
190
            }
191
        }
192
        $stageLabel = 'World Cup';
193
        if ($altNote) {
194
            $stageLabel = str_replace('FIFA World Cup, ', 'World Cup ', $altNote);
195
            $stageLabel = str_replace('FIFA ', '', $stageLabel);
196
        }
197
198
        $upcomingTimestamp = strtotime($upcomingResult['date_raw']);
199
        $gameDateStr       = date('Y-m-d', $upcomingTimestamp);
200
        $todayStr          = date('Y-m-d');
201
        $tomorrowStr       = date('Y-m-d', strtotime('tomorrow'));
202
203
        if ($gameDateStr === $todayStr) {
204
            $relativeUpcoming = 'Today, ' . date('g:i A', $upcomingTimestamp);
205
        } elseif ($gameDateStr === $tomorrowStr) {
206
            $relativeUpcoming = 'Tomorrow, ' . date('g:i A', $upcomingTimestamp);
207
        } else {
208
            $relativeUpcoming = date('M j, g:i A', $upcomingTimestamp);
209
        }
210
211
        $fifaUpcoming[] = [
212
            'timestamp' => $upcomingTimestamp,
213
            'team_name' => 'USA',
214
            'label'     => $stageLabel,
215
            'vsAt'      => $upcomingResult['is_home'] ? 'vs' : '@',
216
            'opponent'  => $upcomingResult['opponent'],
217
            'date_str'  => $relativeUpcoming,
218
            'date_raw'  => $upcomingResult['date_raw'],
219
            'game_id'   => $upcomingResult['game_id'] ?? null,
220
        ];
221
    }
222
223
    // Process city by city
224
    foreach ($CITIES as $cityKey => $cityData) {
225
        $leaguesData = [];
226
227
        foreach ($cityData['teams'] as $i => $team) {
228
            $leagueKey = strtoupper($team['league']);
229
            if (!isset($leaguesData[$leagueKey])) {
230
                $leaguesData[$leagueKey] = [
231
                    'latest_timestamp' => 0,
232
                    'live'             => [],
233
                    'games'            => [],
234
                    'upcoming'         => []
235
                ];
236
            }
237
        }
238
239
        foreach ($cityData['teams'] as $i => $team) {
240
            $leagueKey  = strtoupper($team['league']);
241
            $sportInfo  = $SPORT_LABELS[$team['league']] ?? null;
242
            $requestKey = "{$cityKey}_{$i}";
243
244
            if (!$sportInfo || empty($rawByKey[$requestKey])) continue;
245
            $rawSchedule = $rawByKey[$requestKey];
246
247
            $games = last_completed_games($rawSchedule, 2);
248
            foreach ($games as $event) {
249
                $gameTimestamp = isset($event['date']) ? strtotime($event['date']) : 0;
250
                if ($gameTimestamp < $oneMonthAgo) continue;
251
252
                $result = summarize_game($event, $team['abbr']);
253
                if (!$result) continue;
254
255
                $gameDateStr   = date('Y-m-d', $gameTimestamp);
256
                $todayStr      = date('Y-m-d');
257
                $yesterdayStr  = date('Y-m-d', strtotime('yesterday'));
258
                $twoDaysAgoStr = date('Y-m-d', strtotime('-2 days'));
259
260
                if ($gameDateStr === $todayStr) $relativeDate = 'Today';
261
                elseif ($gameDateStr === $yesterdayStr) $relativeDate = 'Yesterday';
262
                elseif ($gameDateStr === $twoDaysAgoStr) $relativeDate = '2 days ago';
263
                else $relativeDate = date('M j', $gameTimestamp);
264
265
                $outcomeLabels = ['win' => 'Won', 'loss' => 'Lost', 'tie' => 'Tied', 'postponed' => 'Postponed'];
266
                $outcome = $outcomeLabels[$result['status']] ?? 'Final';
267
                $vsAt    = $result['is_home'] ? 'vs' : '@';
268
269
                $gameRecord = [
270
                    'timestamp'  => $gameTimestamp,
271
                    'team_name'  => $team['name'],
272
                    'label'      => $sportInfo['label'],
273
                    'outcome'    => $outcome,
274
                    'vsAt'       => $vsAt,
275
                    'opponent'   => $result['opponent'],
276
                    'team_score' => $result['team_score'],
277
                    'opp_score'  => $result['opp_score'],
278
                    'date_str'   => $relativeDate,
279
                    'date_raw'   => $result['date_raw'],
280
                    'game_id'    => $result['game_id'] ?? null,
281
                ];
282
283
                $leaguesData[$leagueKey]['games'][] = $gameRecord;
284
                $allLeaguesData[$leagueKey]['games'][] = $gameRecord;
285
286
                if ($gameTimestamp > $leaguesData[$leagueKey]['latest_timestamp']) {
287
                    $leaguesData[$leagueKey]['latest_timestamp'] = $gameTimestamp;
288
                }
289
                if ($gameTimestamp > $allLeaguesData[$leagueKey]['latest_timestamp']) {
290
                    $allLeaguesData[$leagueKey]['latest_timestamp'] = $gameTimestamp;
291
                }
292
            }
293
294
            $upcomingResult = get_upcoming_game($rawSchedule, $team['abbr']);
295
            if ($upcomingResult) {
296
                $upcomingTimestamp = strtotime($upcomingResult['date_raw']);
297
                $gameDateStr       = date('Y-m-d', $upcomingTimestamp);
298
                $todayStr          = date('Y-m-d');
299
                $tomorrowStr       = date('Y-m-d', strtotime('tomorrow'));
300
301
                if ($gameDateStr === $todayStr) {
302
                    $relativeUpcoming = 'Today, ' . date('g:i A', $upcomingTimestamp);
303
                } elseif ($gameDateStr === $tomorrowStr) {
304
                    $relativeUpcoming = 'Tomorrow, ' . date('g:i A', $upcomingTimestamp);
305
                } else {
306
                    $relativeUpcoming = date('M j, g:i A', $upcomingTimestamp);
307
                }
308
309
                $upcomingRecord = [
310
                    'timestamp' => $upcomingTimestamp,
311
                    'team_name' => $team['name'],
312
                    'label'     => $sportInfo['label'],
313
                    'vsAt'      => $upcomingResult['is_home'] ? 'vs' : '@',
314
                    'opponent'  => $upcomingResult['opponent'],
315
                    'date_str'  => $relativeUpcoming,
316
                    'date_raw'  => $upcomingResult['date_raw'],
317
                    'game_id'   => $upcomingResult['game_id'] ?? null,
318
                ];
319
320
                $leaguesData[$leagueKey]['upcoming'][] = $upcomingRecord;
321
                $allLeaguesData[$leagueKey]['upcoming'][] = $upcomingRecord;
322
            }
323
        }
324
325
        if (!empty($fifaGames) || !empty($fifaUpcoming)) {
326
            $leaguesData['FIFA'] = [
327
                'latest_timestamp' => $fifaLatestTimestamp,
328
                'live'             => [],
329
                'games'            => $fifaGames,
330
                'upcoming'         => $fifaUpcoming
331
            ];
332
        }
333
334
        uasort($leaguesData, function ($a, $b) {
335
            return $b['latest_timestamp'] <=> $a['latest_timestamp'];
336
        });
337
338
        foreach ($leaguesData as &$data) {
339
            usort($data['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; });
340
            usort($data['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; });
341
        }
342
        unset($data);
343
344
        $database[$cityKey] = [
345
            'label'   => $cityData['label'],
346
            'is_all'  => false,
347
            'leagues' => $leaguesData
348
        ];
349
    }
350
351
    if (!empty($fifaGames) || !empty($fifaUpcoming)) {
352
        $allLeaguesData['FIFA'] = [
353
            'latest_timestamp' => $fifaLatestTimestamp,
354
            'live'             => [],
355
            'games'            => $fifaGames,
356
            'upcoming'         => $fifaUpcoming
357
        ];
358
    }
359
360
    uasort($allLeaguesData, function ($a, $b) {
361
        return $b['latest_timestamp'] <=> $a['latest_timestamp'];
362
    });
363
364
    foreach ($allLeaguesData as &$data) {
365
        usort($data['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; });
366
        usort($data['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; });
367
    }
368
    unset($data);
369
370
    $database['all'] = [
371
        'label'   => 'All Cities',
372
        'is_all'  => true,
373
        'leagues' => $allLeaguesData
374
    ];
375
376
    $majorEventsOut = [];
377
    foreach ($MAJOR_EVENTS as $i => $evt) {
378
        $scoreboardData = $rawByKey["major_{$i}"] ?? null;
379
        $championshipEvent = find_championship_game(
380
            $scoreboardData,
381
            $evt['keywords'],
382
            $evt['requires_postseason_flag']
383
        );
384
        if (!$championshipEvent) continue;
385
        $summary = summarize_championship_game($championshipEvent, $evt['label']);
386
        if (!$summary) continue;
387
388
        $eventTimestamp = isset($summary['date_raw']) ? strtotime($summary['date_raw']) : 0;
389
        if ($eventTimestamp < $oneMonthAgo) continue;
390
        $majorEventsOut[] = $summary;
391
    }
392
393
    usort($majorEventsOut, function ($a, $b) {
394
        return strtotime($b['date_raw'] ?? 'now') <=> strtotime($a['date_raw'] ?? 'now');
395
    });
396
397
    $database['_major_events'] = $majorEventsOut;
398
399
    $allTeamsOut = [];
400
    foreach ($CITIES as $cityKey => $cityData) {
401
        foreach ($cityData['teams'] as $team) {
402
            $allTeamsOut[] = [
403
                'name'      => $team['name'],
404
                'league'    => strtoupper($team['league']),
405
                'abbr'      => $team['abbr'],
406
                'city_key'  => $cityKey,
407
                'city_label'=> $cityData['label'],
408
                'city'      => $team['city'] ?? '',
409
                'state'     => $team['state'] ?? '',
410
                'search'    => $team['search'] ?? '',
411
                'mascot'    => $team['mascot'] ?? '',
412
            ];
413
        }
414
    }
415
    if (!empty($fifaGames) || !empty($fifaUpcoming)) {
416
        $allTeamsOut[] = [
417
            'name' => 'USA', 'league' => 'FIFA', 'abbr' => 'usa',
418
            'city_key' => 'all', 'city_label' => 'National',
419
            'city' => '', 'state' => 'United States', 'search' => 'USMNT',
420
        ];
421
    }
422
    $database['_all_teams'] = $allTeamsOut;
423
424
    return $database;
425
}
426
427
/**
428
 * Mutates a provided database in-place, adding live games extracted from scoreboards
429
 * while removing those same games from the upcoming array to prevent duplication.
430
 */
431
function apply_live_scoreboards(array &$database, array $liveRawByKey, array $CITIES, ?callable $log = null): void
432
{
433
    // Which league each build_live_step_list() key represents. We need this
434
    // because ESPN abbreviations are NOT unique across leagues — e.g. "phi"
435
    // is reused by the Eagles (NFL), 76ers (NBA), Phillies (MLB), and Flyers
436
    // (NHL). A single global abbr->team lookup would let later teams silently
437
    // overwrite earlier ones sharing the same city abbreviation. Since each
438
    // scoreboard fetch is already scoped to one league, we build a lookup
439
    // PER LEAGUE instead, so "phi" in the MLB scoreboard only ever resolves
440
    // against MLB teams.
441
    $keyToLeague = [
442
        'live_nfl'  => 'NFL',
443
        'live_mlb'  => 'MLB',
444
        'live_nhl'  => 'NHL',
445
        'live_nba'  => 'NBA',
446
        'live_wnba' => 'WNBA',
447
        'live_ncaam' => 'NCAAM',
448
        'live_ncaaw' => 'NCAAW',
449
        'live_fifa' => 'FIFA',
450
    ];
451
452
    $teamLookupByLeague = [];
453
    foreach ($CITIES as $cKey => $cData) {
454
        foreach ($cData['teams'] as $team) {
455
            $leagueKey = strtoupper($team['league']);
456
            $teamLookupByLeague[$leagueKey][strtolower($team['abbr'])] = [
457
                'city_key' => $cKey,
458
                'league'   => $leagueKey,
459
                'name'     => $team['name']
460
            ];
461
        }
462
    }
463
    // FIFA isn't in $CITIES (it's the national team, not a city team), and we
464
    // only ever care about the USA's match, so this scoping also naturally
465
    // filters the FIFA scoreboard down to just USA's game.
466
    $teamLookupByLeague['FIFA']['usa'] = ['city_key' => 'all', 'league' => 'FIFA', 'name' => 'USA'];
467
468
    foreach ($liveRawByKey as $key => $scoreboardData) {
469
        if (!$scoreboardData || empty($scoreboardData['events'])) {
470
            if ($log) $log("apply key=$key: no scoreboard data or no events");
471
            continue;
472
        }
473
474
        $league = $keyToLeague[$key] ?? null;
475
        if (!$league || empty($teamLookupByLeague[$league])) {
476
            if ($log) $log("apply key=$key: unknown league mapping — skipping");
477
            continue;
478
        }
479
        $teamLookup = $teamLookupByLeague[$league];
480
481
        $eventTotal = count($scoreboardData['events']);
482
        $inCount = 0;
483
        $matched = 0;
484
        $unmatched = [];
485
486
        foreach ($scoreboardData['events'] as $event) {
487
            $state = $event['status']['type']['state'] ?? '';
488
            if ($state !== 'in') continue; // only process live games
489
            $inCount++;
490
491
            $competition = $event['competitions'][0] ?? null;
492
            if (!$competition || empty($competition['competitors'])) continue;
493
494
            $comps = $competition['competitors'];
495
            if (count($comps) < 2) continue;
496
497
            // Evaluate for each competitor in our tracked list
498
            foreach ($comps as $idx => $competitor) {
499
                $abbr = strtolower($competitor['team']['abbreviation'] ?? '');
500
                if (!isset($teamLookup[$abbr])) {
501
                    $unmatched[$abbr] = true;
502
                    continue;
503
                }
504
                $matched++;
505
506
                $info = $teamLookup[$abbr];
507
                $opponent = $idx === 0 ? $comps[1] : $comps[0];
508
509
                $teamScore = (int) extract_score($competitor['score'] ?? 0);
510
                $oppScore  = (int) extract_score($opponent['score'] ?? 0);
511
512
                if ($teamScore > $oppScore) $liveStatus = 'Winning';
513
                elseif ($teamScore < $oppScore) $liveStatus = 'Losing';
514
                else $liveStatus = 'Tied';
515
516
                $progress = $competition['status']['type']['detail'] ?? 'In Progress';
517
518
                $record = [
519
                    'timestamp'  => strtotime($event['date']),
520
                    'team_name'  => $info['name'],
521
                    'label'      => $info['league'],
522
                    'outcome'    => 'live',
523
                    'live_status'=> $liveStatus,
524
                    'team_score' => $teamScore,
525
                    'opp_score'  => $oppScore,
526
                    'vsAt'       => ($competitor['homeAway'] ?? '') === 'home' ? 'vs' : '@',
527
                    'opponent'   => $opponent['team']['displayName'] ?? ($opponent['team']['name'] ?? 'Opponent'),
528
                    'progress'   => $progress,
529
                    'date_raw'   => $event['date'],
530
                    'game_id'    => $event['id'] ?? null,
531
                ];
532
533
                $applyLiveToBucket = function(&$leagueBucket) use ($record) {
534
                    if (!isset($leagueBucket['live'])) $leagueBucket['live'] = [];
535
                    $leagueBucket['live'][] = $record;
536
                    
537
                    // Drop from upcoming if it's currently live
538
                    if (isset($leagueBucket['upcoming'])) {
539
                        foreach ($leagueBucket['upcoming'] as $k => $up) {
540
                            if (strtolower($up['opponent']) === strtolower($record['opponent'])) {
541
                                unset($leagueBucket['upcoming'][$k]);
542
                            }
543
                        }
544
                        $leagueBucket['upcoming'] = array_values($leagueBucket['upcoming']);
545
                    }
546
                };
547
548
                // National teams (city_key === 'all', e.g. USA at the World
549
                // Cup) aren't tied to one region — aggregate_database() copies
550
                // their completed/upcoming games into EVERY city's FIFA bucket,
551
                // so a live national game must fan out the same way. City teams
552
                // write only to their own region.
553
                $isNational = ($info['city_key'] === 'all');
554
                $wroteCityCount = 0;
555
556
                if ($isNational) {
557
                    foreach (array_keys($database) as $dbKey) {
558
                        if ($dbKey === 'all') continue; // 'all' handled separately below
559
                        if (!isset($database[$dbKey]['leagues'][$info['league']])) continue;
560
                        $applyLiveToBucket($database[$dbKey]['leagues'][$info['league']]);
561
                        $wroteCityCount++;
562
                    }
563
                } elseif (isset($database[$info['city_key']]['leagues'][$info['league']])) {
564
                    $applyLiveToBucket($database[$info['city_key']]['leagues'][$info['league']]);
565
                    $wroteCityCount = 1;
566
                }
567
568
                $wroteAll = false;
569
                if (isset($database['all']['leagues'][$info['league']])) {
570
                    $applyLiveToBucket($database['all']['leagues'][$info['league']]);
571
                    $wroteAll = true;
572
                }
573
574
                if ($log) {
575
                    $log(sprintf(
576
                        'apply key=%s matched %s (%s) abbr=%s %s %d-%d vs %s [%s] -> %s region bucket(s): %d written, all bucket: %s',
577
                        $key,
578
                        $info['name'],
579
                        $info['league'],
580
                        $abbr,
581
                        $liveStatus,
582
                        $teamScore,
583
                        $oppScore,
584
                        $record['opponent'],
585
                        $progress,
586
                        $isNational ? 'national (all regions)' : $info['city_key'],
587
                        $wroteCityCount,
588
                        $wroteAll ? 'written' : 'MISSING BUCKET'
589
                    ));
590
                }
591
            }
592
        }
593
594
        if ($log) {
595
            $log(sprintf(
596
                'apply key=%s summary: league=%s events=%d live(in)=%d competitorsMatched=%d unmatchedAbbrs=[%s]',
597
                $key,
598
                $league,
599
                $eventTotal,
600
                $inCount,
601
                $matched,
602
                implode(',', array_keys($unmatched))
603
            ));
604
        }
605
    }
606
}
607
608
function render_index_html(array $database, array $CITIES, string $timestamp): string
609
{
610
    $jsonDatabase = json_encode($database, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
611
    $updatedAtMs  = time() * 1000; // build time, for the "last updated" hover tooltip
612
613
    $optionsHtml = '';
614
    foreach ($CITIES as $key => $city) {
615
        $selected = ($key === 'chicago_wisconsin') ? ' selected' : '';
616
        $optionsHtml .= '                <option value="' . htmlspecialchars($key) . '"' . $selected . '>' . htmlspecialchars($city['label']) . '</option>' . "\n";
617
    }
618
    $optionsHtml .= '                <option value="all">All Cities</option>' . "\n";
619
    $optionsHtml .= '                <option value="__custom__">Customize&hellip;</option>' . "\n";
620
621
    $indexTemplate = <<<HTML
622
<?php
623
// AUTO-GENERATED at {$timestamp}
624
header("Cache-Control: public, max-age=300");
625
?>
626
<!DOCTYPE html>
627
<html lang="en">
628
<head>
629
<meta charset="UTF-8">
630
<meta name="viewport" content="width=device-width, initial-scale=1.0">
631
<meta name="description" content="Catch up on your local pro teams in seconds. See recent game results and upcoming games over the next two weeks—all in one place.">
632
<title>Casual Fan Sports Report</title>
633
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
634
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
635
<link rel="shortcut icon" href="/favicon.ico" />
636
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
637
<meta name="apple-mobile-web-app-title" content="Casual Fan Sports Report" />
638
<link rel="manifest" href="/site.webmanifest" />
639
<style>
640
    :root {
641
        color-scheme: dark light;
642
        --bg-color: #f8fafc;
643
        --card-bg: #ffffff;
644
        --text-primary: #0f172a;
645
        --text-secondary: #475569;
646
        --border: #e2e8f0;
647
        --radius: 8px;
648
        --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
649
        
650
        --upcoming-bg: #f0f9ff;
651
        --upcoming-border: #bae6fd;
652
        --win-bg: #f0fdf4;
653
        --win-border: #bbf7d0;
654
        --loss-bg: #fef2f2;
655
        --loss-border: #fecaca;
656
        --neutral-bg: #fefce8;
657
        --neutral-border: #fde68a;
658
    }
659
660
    @media (prefers-color-scheme: dark) {
661
        :root {
662
            --bg-color: #0f172a;
663
            --card-bg: #1e293b;
664
            --text-primary: #f8fafc;
665
            --text-secondary: #bccbe1;
666
            --border: #334155;
667
            --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.5);
668
            
669
            --upcoming-bg: #082f49;
670
            --upcoming-border: #0369a1;
671
            --win-bg: #14532d;
672
            --win-border: #166534;
673
            --loss-bg: #7f1d1d;
674
            --loss-border: #991b1b;
675
            --neutral-bg: #422006;
676
            --neutral-border: #a16207;
677
        }
678
    }
679
680
    body {
681
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
682
        background-color: var(--bg-color);
683
        color: var(--text-primary);
684
        line-height: 1.3;
685
        margin: 0;
686
        padding: 0.75rem 0.75rem 1.5rem;
687
        font-size: 15px;
688
    }
689
    .container { max-width: 650px; margin: 0 auto; }
690
    h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -0.025em; margin: 0 0 0.5rem 0; }
691
692
    .selector-wrapper {
693
        background: var(--card-bg); padding: 0.5rem 0.625rem; border-radius: var(--radius);
694
        border: 1px solid var(--border); box-shadow: var(--shadow);
695
        margin-bottom: 0.75rem; display: flex; flex-direction: row; align-items: center; gap: 0.5rem;
696
    }
697
    label { font-weight: 600; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; }
698
    select {
699
        padding: 0.3rem 0.4rem; border-radius: 6px; border: 1px solid var(--border);
700
        background-color: var(--card-bg); font-size: 0.9rem; color: var(--text-primary);
701
        width: 100%; cursor: pointer;
702
    }
703
    .selector-wrapper select { flex: 1; width: auto; min-width: 0; }
704
    .selector-wrapper .toggle-editor-btn { flex-shrink: 0; }
705
706
    h2 {
707
        font-size: 0.7rem; color: var(--text-secondary); margin: 0.6rem 0 0.3rem 0;
708
        text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border);
709
        padding-bottom: 0.15rem;
710
    }
711
    h2:first-child { margin-top: 0; }
712
    .game-card {
713
        background: var(--card-bg); padding: 0.35rem 0.6rem; border-radius: 6px;
714
        border: 1px solid var(--border); margin-bottom: 0.3rem;
715
        display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.4rem;
716
        font-size: 0.85rem;
717
    }
718
    
719
    .game-card.upcoming { background: var(--upcoming-bg); border-color: var(--upcoming-border); }
720
    .game-card.win { background: var(--win-bg); border-color: var(--win-border); }
721
    .game-card.loss { background: var(--loss-bg); border-color: var(--loss-border); }
722
    .game-card.tie,
723
    .game-card.postponed,
724
    .game-card.both-sides { background: var(--neutral-bg); border-color: var(--neutral-border); }
725
726
    /* Dates carry a "time ago" tooltip on hover; on touch, tap to reveal it */
727
    .game-date, .event-date-inner, #scores-updated-ts, #page-loaded-line {
728
        cursor: help; border-bottom: 1px dotted var(--border);
729
    }
730
    /* While a date is tapped-open showing its relative time */
731
    .game-date.rel-open, .event-date-inner.rel-open,
732
    #scores-updated-ts.rel-open, #page-loaded-line.rel-open {
733
        color: var(--text-primary); font-style: italic;
734
    }
735
    
736
    /* Live Game Styles */
737
    .game-card.live { background: #fff1f2; border-color: #fecdd3; border-left: 3px solid #e11d48; }
738
    @media (prefers-color-scheme: dark) {
739
        .game-card.live { background: #4c0519; border-color: #881337; border-left: 3px solid #f43f5e; }
740
    }
741
    .live-indicator { 
742
        color: #e11d48; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; 
743
        letter-spacing: 0.05em; animation: pulse 2s infinite; display: inline-block; margin-right: 0.35rem;
744
    }
745
    @media (prefers-color-scheme: dark) { .live-indicator { color: #f43f5e; } }
746
    @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
747
    
748
    .game-card strong { color: var(--text-primary); font-weight: 600; }
749
    .game-details { color: var(--text-secondary); }
750
    .no-results { color: var(--text-secondary); font-style: italic; padding: 0.15rem 0; margin: 0 0 0.3rem 0; font-size: 0.85rem; }
751
    
752
    .timestamp-block {
753
        display: grid; grid-template-columns: auto auto; gap: 0.15rem 0.4rem;
754
        justify-content: center; margin-top: 1rem;
755
        font-size: 0.7rem; color: var(--text-secondary);
756
    }
757
    .timestamp-block .ts-label { text-align: right; }
758
    .timestamp-block .ts-value { text-align: left; }
759
    .update-schedule { font-size: 0.7rem; color: var(--text-secondary); text-align: center; margin-top: 0.5rem; }
760
761
    /* Major events (Super Bowl, Finals, World Cup, etc.) */
762
    .major-events { margin-top: 0.75rem; }
763
    .major-event-card {
764
        background: var(--card-bg); padding: 0.45rem 0.65rem; border-radius: 6px;
765
        border: 1px solid var(--border); margin-bottom: 0.3rem; font-size: 0.85rem;
766
        border-left: 3px solid #eab308;
767
    }
768
    .major-event-card .event-label {
769
        font-weight: 700; text-transform: uppercase; font-size: 0.65rem;
770
        letter-spacing: 0.05em; color: #b45309; display: block; margin-bottom: 0.15rem;
771
    }
772
    .major-event-card .matchup { color: var(--text-primary); font-weight: 600; }
773
    .major-event-card .matchup .winner { color: #15803d; }
774
    .major-event-card .event-date { color: var(--text-secondary); font-size: 0.75rem; margin-left: 0.35rem; }
775
776
    /* Customize panel */
777
    .customize-panel {
778
        background: var(--card-bg); padding: 0.6rem 0.65rem; border-radius: var(--radius);
779
        border: 1px solid var(--border); box-shadow: var(--shadow); margin-bottom: 0.75rem;
780
    }
781
    .toggle-editor-btn {
782
        padding: 0.3rem 0.55rem; border-radius: 6px; border: 1px solid var(--border);
783
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.72rem;
784
        cursor: pointer; white-space: nowrap; font-weight: 600;
785
    }
786
    .toggle-editor-btn:hover { background-color: var(--border); }
787
    .customize-panel .customize-row {
788
        display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;
789
    }
790
    .customize-search {
791
        flex: 1; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border);
792
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.85rem;
793
    }
794
    .select-all-btn {
795
        padding: 0.35rem 0.6rem; border-radius: 6px; border: 1px solid var(--border);
796
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.75rem;
797
        cursor: pointer; white-space: nowrap; font-weight: 600;
798
    }
799
    .select-all-btn:hover { background-color: var(--border); }
800
    .team-checklist {
801
        max-height: 260px; overflow-y: auto; border: 1px solid var(--border);
802
        border-radius: 6px; padding: 0.35rem 0.5rem;
803
    }
804
    .league-group-header {
805
        position: sticky; top: 0; z-index: 1; background: var(--card-bg);
806
        font-weight: 700; font-size: 0.68rem; color: var(--text-secondary);
807
        text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.2rem 0;
808
        padding: 0.4rem 0 0.15rem 0; border-bottom: 1px solid var(--border);
809
    }
810
    .league-group-header:first-child { padding-top: 0.1rem; }
811
    .league-group-header.hidden { display: none; }
812
    .team-check-item {
813
        display: flex; align-items: center; gap: 0.4rem; padding: 0.2rem 0;
814
        font-size: 0.82rem;
815
    }
816
    .team-check-item input { cursor: pointer; }
817
    .team-check-item label { text-transform: none; font-weight: 400; font-size: 0.82rem; color: var(--text-primary); cursor: pointer; display: flex; align-items: baseline; gap: 0.35rem; }
818
    .team-check-item .team-city-tag {
819
        font-size: 0.68rem; color: var(--text-secondary); font-weight: 400;
820
    }
821
    .team-check-item.hidden { display: none; }
822
    .customize-empty-hint { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; padding: 0.3rem 0; }
823
824
    .stale-banner {
825
        background: var(--neutral-bg); border: 1px solid var(--neutral-border);
826
        color: var(--text-primary); border-radius: var(--radius);
827
        padding: 0.5rem 0.7rem; margin-bottom: 0.75rem; font-size: 0.85rem;
828
    }
829
    .stale-banner a { color: var(--text-primary); font-weight: 600; }
830
    .stale-banner a:hover { text-decoration: none; }
831
832
    @media (max-width: 480px) {
833
        body { padding: 0.5rem 0.5rem 1rem; font-size: 14px; }
834
        h1 { font-size: 1.1rem; }
835
        .game-card { font-size: 0.8rem; padding: 0.3rem 0.5rem; }
836
        h3 { font-size: 0.65rem; }
837
    }
838
</style>
839
</head>
840
<body>
841
842
<main class="container">
843
    <h1>Casual Fan Sports Report</h1>
844
845
    <div id="stale-banner" class="stale-banner" hidden>
846
        New data is available. <a href="#" id="stale-refresh-link">Refresh to see the latest scores</a>.
847
    </div>
848
849
    <div class="selector-wrapper">
850
        <label for="city">City</label>
851
        <select id="city">
852
{$optionsHtml}
853
        </select>
854
        <button type="button" id="toggle-customize-editor" class="toggle-editor-btn" hidden>Edit teams</button>
855
    </div>
856
857
    <div id="customize-panel" class="customize-panel" hidden>
858
        <div class="customize-row">
859
            <input type="text" id="team-search" class="customize-search" placeholder="Search teams, leagues, or cities (e.g. &quot;NBA&quot;, &quot;Bears&quot;, &quot;Chicago&quot;)">
860
            <button type="button" id="select-all-shown" class="select-all-btn">Select all shown</button>
861
        </div>
862
        <div id="team-checklist" class="team-checklist"></div>
863
    </div>
864
865
    <div id="results-container"></div>
866
867
    <div class="timestamp-block">
868
        <div class="ts-label">Scores last updated:</div>
869
        <div class="ts-value"><span id="scores-updated-ts">{$timestamp}</span></div>
870
        <div class="ts-label">Page loaded:</div>
871
        <div class="ts-value"><span id="page-loaded-line"></span></div>
872
    </div>
873
    <div class="update-schedule">Live scores updated every 30 minutes, Results updated every 2 hours.</div>
874
875
</main>
876
877
<script>
878
    const sportsData = {$jsonDatabase};
879
    const majorEvents = sportsData._major_events || [];
880
    const allTeams = sportsData._all_teams || [];
881
    const SCORES_UPDATED_AT = {$updatedAtMs};
882
883
    // Map "LEAGUE::TeamName" -> branding city, so cross-region views (All
884
    // Cities / Customize) can prefix a bare team name with its city. In those
885
    // views the region isn't shown, so "Aces" alone is ambiguous — "Las Vegas
886
    // Aces" isn't. College teams whose name is already a place have no city
887
    // and stay unprefixed.
888
    const cityByTeam = {};
889
    for (const t of allTeams) {
890
        if (t.city) cityByTeam[`\${t.league}::\${t.name}`] = t.city;
891
    }
892
893
    // A team's display name for the current view: prefixed with its city only
894
    // in cross-region views; single-region views already imply the city.
895
    function displayTeamName(league, teamName, crossRegion) {
896
        if (!crossRegion) return teamName;
897
        const city = cityByTeam[`\${league}::\${teamName}`];
898
        return city ? `\${city} \${teamName}` : teamName;
899
    }
900
901
    const citySelect = document.getElementById('city');
902
    const resultsContainer = document.getElementById('results-container');
903
    const customizePanel = document.getElementById('customize-panel');
904
    const teamChecklist = document.getElementById('team-checklist');
905
    const teamSearch = document.getElementById('team-search');
906
    const selectAllShownBtn = document.getElementById('select-all-shown');
907
    const toggleCustomizeEditorBtn = document.getElementById('toggle-customize-editor');
908
909
    const CITY_STORAGE_KEY = 'sportsfan_selected_city';
910
    const CUSTOM_TEAMS_STORAGE_KEY = 'sportsfan_custom_teams';
911
    const CUSTOM_EDITOR_COLLAPSED_KEY = 'sportsfan_custom_editor_collapsed';
912
    const CUSTOM_VALUE = '__custom__';
913
914
    function getUrlCity() {
915
        const params = new URLSearchParams(window.location.search);
916
        return params.get('city');
917
    }
918
919
    function getStoredCustomTeams() {
920
        try {
921
            const raw = window.localStorage.getItem(CUSTOM_TEAMS_STORAGE_KEY);
922
            const parsed = raw ? JSON.parse(raw) : [];
923
            return Array.isArray(parsed) ? parsed : [];
924
        } catch (e) {
925
            return [];
926
        }
927
    }
928
929
    function storeCustomTeams(teamKeys) {
930
        try {
931
            window.localStorage.setItem(CUSTOM_TEAMS_STORAGE_KEY, JSON.stringify(teamKeys));
932
        } catch (e) {
933
        }
934
    }
935
936
    function teamKey(team) {
937
        return `\${team.league}::\${team.abbr}::\${team.name}`;
938
    }
939
940
    function buildCustomCityData(selectedKeys) {
941
        const selectedSet = new Set(selectedKeys);
942
        const leagues = {};
943
944
        for (const team of allTeams) {
945
            if (!selectedSet.has(teamKey(team))) continue;
946
947
            const cityBucket = sportsData[team.city_key];
948
            if (!cityBucket) continue;
949
950
            const leagueData = cityBucket.leagues[team.league];
951
            if (!leagueData) continue;
952
953
            if (!leagues[team.league]) {
954
                leagues[team.league] = { latest_timestamp: 0, live: [], games: [], upcoming: [] };
955
            }
956
957
            const teamLive = (leagueData.live || []).filter(g => g.team_name === team.name);
958
            const teamGames = (leagueData.games || []).filter(g => g.team_name === team.name);
959
            const teamUpcoming = (leagueData.upcoming || []).filter(g => g.team_name === team.name);
960
961
            leagues[team.league].live.push(...teamLive);
962
            leagues[team.league].games.push(...teamGames);
963
            leagues[team.league].upcoming.push(...teamUpcoming);
964
        }
965
966
        for (const key of Object.keys(leagues)) {
967
            leagues[key].games.sort((a, b) => b.timestamp - a.timestamp);
968
            leagues[key].upcoming.sort((a, b) => a.timestamp - b.timestamp);
969
            leagues[key].latest_timestamp = leagues[key].games.length
970
                ? leagues[key].games[0].timestamp
971
                : 0;
972
        }
973
974
        return { label: 'Customize', is_all: false, leagues };
975
    }
976
977
    function getStoredCity() {
978
        try { return window.localStorage.getItem(CITY_STORAGE_KEY); } catch (e) { return null; }
979
    }
980
981
    function storeCity(cityId) {
982
        try { window.localStorage.setItem(CITY_STORAGE_KEY, cityId); } catch (e) { }
983
    }
984
985
    function updateUrl(cityId) {
986
        const url = new URL(window.location.href);
987
        url.searchParams.set('city', cityId);
988
        window.history.replaceState({}, '', url);
989
    }
990
991
    function resolveInitialCity() {
992
        const urlCity = getUrlCity();
993
        if (urlCity && (sportsData[urlCity] || urlCity === CUSTOM_VALUE)) return urlCity;
994
995
        const storedCity = getStoredCity();
996
        if (storedCity && (sportsData[storedCity] || storedCity === CUSTOM_VALUE)) return storedCity;
997
998
        return citySelect.value;
999
    }
1000
1001
    function matchesSearch(team, query) {
1002
        if (!query) return true;
1003
        const q = query.toLowerCase();
1004
        const haystack = [
1005
            team.name, team.mascot, team.league, team.city_label,
1006
            team.city, team.state, team.search
1007
        ].filter(Boolean).join(' ').toLowerCase();
1008
        return haystack.includes(q);
1009
    }
1010
1011
    function renderTeamChecklist() {
1012
        const storedKeys = new Set(getStoredCustomTeams());
1013
        const query = teamSearch.value.trim();
1014
1015
        const sortedTeams = [...allTeams].sort((a, b) => {
1016
            if (a.league !== b.league) return a.league.localeCompare(b.league);
1017
            return a.name.localeCompare(b.name);
1018
        });
1019
1020
        let html = '';
1021
        let currentLeague = null;
1022
1023
        for (const team of sortedTeams) {
1024
            const key = teamKey(team);
1025
            const visible = matchesSearch(team, query);
1026
            const checked = storedKeys.has(key) ? ' checked' : '';
1027
            const inputId = `team-check-\${escapeHTML(key)}`;
1028
1029
            if (team.league !== currentLeague) {
1030
                currentLeague = team.league;
1031
                html += `<div class="league-group-header" data-league="\${escapeHTML(currentLeague)}">\${escapeHTML(currentLeague)}</div>`;
1032
            }
1033
1034
            html += `
1035
                <div class="team-check-item\${visible ? '' : ' hidden'}" data-team-key="\${escapeHTML(key)}" data-league="\${escapeHTML(team.league)}">
1036
                    <input type="checkbox" id="\${inputId}"\${checked} data-team-key="\${escapeHTML(key)}">
1037
                    <label for="\${inputId}">\${escapeHTML((team.city ? team.city + ' ' + team.name : team.name) + (team.mascot ? ' ' + team.mascot : ''))} <span class="team-city-tag">- \${escapeHTML(team.city_label)}</span></label>
1038
                </div>
1039
            `;
1040
        }
1041
1042
        if (!sortedTeams.length) {
1043
            html = `<p class="customize-empty-hint">No teams available.</p>`;
1044
        }
1045
        teamChecklist.innerHTML = html;
1046
        teamChecklist.querySelectorAll('input[type="checkbox"]').forEach(input => {
1047
            input.addEventListener('change', onTeamCheckboxChange);
1048
        });
1049
        updateLeagueHeaderVisibility();
1050
        updateSelectAllShownLabel();
1051
    }
1052
1053
    function updateLeagueHeaderVisibility() {
1054
        const headers = Array.from(teamChecklist.querySelectorAll('.league-group-header'));
1055
        const items = Array.from(teamChecklist.querySelectorAll('.team-check-item'));
1056
1057
        headers.forEach(header => {
1058
            const anyVisible = items.some(item =>
1059
                item.dataset.league === header.dataset.league && !item.classList.contains('hidden')
1060
            );
1061
            header.classList.toggle('hidden', !anyVisible);
1062
        });
1063
    }
1064
1065
    function getCheckedKeysFromDom() {
1066
        return Array.from(teamChecklist.querySelectorAll('input[type="checkbox"]:checked'))
1067
            .map(input => input.dataset.teamKey);
1068
    }
1069
1070
    function onTeamCheckboxChange() {
1071
        storeCustomTeams(getCheckedKeysFromDom());
1072
        updateSelectAllShownLabel();
1073
        if (citySelect.value === CUSTOM_VALUE) renderResults();
1074
    }
1075
1076
    function filterChecklist() {
1077
        const query = teamSearch.value.trim();
1078
        teamChecklist.querySelectorAll('.team-check-item').forEach(item => {
1079
            const key = item.dataset.teamKey;
1080
            const team = allTeams.find(t => teamKey(t) === key);
1081
            const visible = team ? matchesSearch(team, query) : true;
1082
            item.classList.toggle('hidden', !visible);
1083
        });
1084
        updateLeagueHeaderVisibility();
1085
        updateSelectAllShownLabel();
1086
    }
1087
1088
    function getShownInputs() {
1089
        return Array.from(teamChecklist.querySelectorAll('.team-check-item:not(.hidden) input[type="checkbox"]'));
1090
    }
1091
1092
    function allShownChecked() {
1093
        const shown = getShownInputs();
1094
        return shown.length > 0 && shown.every(input => input.checked);
1095
    }
1096
1097
    function updateSelectAllShownLabel() {
1098
        selectAllShownBtn.textContent = allShownChecked() ? 'Deselect all shown' : 'Select all shown';
1099
    }
1100
1101
    function toggleAllShown() {
1102
        const shouldCheck = !allShownChecked();
1103
        getShownInputs().forEach(input => { input.checked = shouldCheck; });
1104
        storeCustomTeams(getCheckedKeysFromDom());
1105
        updateSelectAllShownLabel();
1106
        if (citySelect.value === CUSTOM_VALUE) renderResults();
1107
    }
1108
1109
    function isEditorCollapsed() {
1110
        try { return window.localStorage.getItem(CUSTOM_EDITOR_COLLAPSED_KEY) === '1'; } catch (e) { return false; }
1111
    }
1112
1113
    function setEditorCollapsed(collapsed) {
1114
        try { window.localStorage.setItem(CUSTOM_EDITOR_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch (e) { }
1115
        customizePanel.hidden = collapsed;
1116
        toggleCustomizeEditorBtn.textContent = collapsed ? 'Edit teams' : 'Hide picker';
1117
    }
1118
1119
    function updateCustomizeVisibility() {
1120
        const isCustom = citySelect.value === CUSTOM_VALUE;
1121
        // The "Edit teams" button lives next to the city dropdown and only
1122
        // appears in Customize mode; the picker panel is toggled by it.
1123
        toggleCustomizeEditorBtn.hidden = !isCustom;
1124
        if (isCustom) {
1125
            renderTeamChecklist();
1126
            setEditorCollapsed(isEditorCollapsed());
1127
        } else {
1128
            customizePanel.hidden = true;
1129
        }
1130
    }
1131
1132
    function escapeHTML(str) {
1133
        return String(str).replace(/[&<>'"]/g,
1134
            tag => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[tag] || tag)
1135
        );
1136
    }
1137
1138
    // Human "time ago"/"in …" string for a hover tooltip. Accepts an ISO
1139
    // date string or an epoch (ms). Returns '' for anything unparseable.
1140
    function relativeTime(input) {
1141
        let then;
1142
        if (typeof input === 'number') then = input;
1143
        else if (typeof input === 'string' && /^\d+$/.test(input)) then = Number(input);
1144
        else then = Date.parse(input);
1145
        if (!then || isNaN(then)) return '';
1146
        const diffMs = then - Date.now();
1147
        const future = diffMs > 0;
1148
        const s = Math.abs(diffMs) / 1000;
1149
        if (s < 45) return 'just now';
1150
        const units = [
1151
            ['year', 31536000], ['month', 2592000], ['week', 604800],
1152
            ['day', 86400], ['hour', 3600], ['minute', 60]
1153
        ];
1154
        for (const [name, secs] of units) {
1155
            if (s >= secs) {
1156
                const n = Math.round(s / secs);
1157
                const label = `\${n} \${name}\${n === 1 ? '' : 's'}`;
1158
                return future ? `in \${label}` : `\${label} ago`;
1159
            }
1160
        }
1161
        return 'just now';
1162
    }
1163
1164
    // Wrap a baked-in date string in a span whose hover tooltip is the
1165
    // relative time computed from its raw ISO date.
1166
    function dateWithTooltip(dateStr, dateRaw, cls) {
1167
        const rel = relativeTime(dateRaw);
1168
        const titleAttr = rel ? ` title="\${escapeHTML(rel)}"` : '';
1169
        // data-raw lets the tap-to-reveal handler recompute a fresh relative
1170
        // time on touch devices, where the hover tooltip is unavailable.
1171
        const rawAttr = dateRaw ? ` data-raw="\${escapeHTML(String(dateRaw))}"` : '';
1172
        return `<span class="\${cls}"\${titleAttr}\${rawAttr}>\${escapeHTML(dateStr)}</span>`;
1173
    }
1174
1175
    // Collapse two perspectives of the same game (both tracked teams played
1176
    // each other) into one entry. Uses the stable ESPN game_id when present,
1177
    // falling back to a date + sorted-teams composite key. Marks bothSides
1178
    // and prefers the home-team ('vs') perspective for a natural read.
1179
    function dedupeGames(records) {
1180
        const seen = new Map();
1181
        const out = [];
1182
        for (const g of records) {
1183
            const key = (g.game_id != null && g.game_id !== '')
1184
                ? `id:\${g.game_id}`
1185
                : `k:\${g.date_raw}::\${[String(g.team_name), String(g.opponent)].sort().join('|')}`;
1186
            if (seen.has(key)) {
1187
                const entry = out[seen.get(key)];
1188
                entry.bothSides = true;
1189
                if (entry.game.vsAt !== 'vs' && g.vsAt === 'vs') entry.game = g;
1190
            } else {
1191
                seen.set(key, out.length);
1192
                out.push({ game: g, bothSides: false });
1193
            }
1194
        }
1195
        return out;
1196
    }
1197
1198
    function renderMajorEventsHtml() {
1199
        if (!majorEvents.length) return '';
1200
1201
        let html = '<h2>Championships &amp; Major Events</h2><div class="major-events">';
1202
        majorEvents.forEach(evt => {
1203
            const aClass = evt.team_a_winner ? ' winner' : '';
1204
            const bClass = evt.team_b_winner ? ' winner' : '';
1205
1206
            html += `
1207
                <div class="major-event-card">
1208
                    <span class="event-label">\${escapeHTML(evt.event_label)}</span>
1209
                    <span class="matchup">
1210
                        <span class="\${aClass.trim()}">\${escapeHTML(evt.team_a)}\${evt.is_final ? ' ' + evt.team_a_score : ''}</span>
1211
                        \${evt.is_final ? '–' : 'vs'}
1212
                        <span class="\${bClass.trim()}">\${escapeHTML(evt.team_b)}\${evt.is_final ? ' ' + evt.team_b_score : ''}</span>
1213
                    </span>
1214
                    <span class="event-date">(\${dateWithTooltip(evt.date_str, evt.date_raw, 'event-date-inner')})</span>
1215
                </div>
1216
            `;
1217
        });
1218
        html += '</div>';
1219
        return html;
1220
    }
1221
1222
    function renderResults() {
1223
        const cityId = citySelect.value;
1224
        resultsContainer.innerHTML = ''; 
1225
1226
        if (!cityId) return;
1227
1228
        const cityData = cityId === CUSTOM_VALUE
1229
            ? buildCustomCityData(getStoredCustomTeams())
1230
            : sportsData[cityId];
1231
1232
        if (!cityData) return;
1233
1234
        // In All Cities / Customize the same league mixes teams from many
1235
        // regions, so bare team names need a city prefix to be unambiguous.
1236
        const crossRegion = cityId === CUSTOM_VALUE || !!cityData.is_all;
1237
1238
        let html = '';
1239
        const leagueEntries = Object.entries(cityData.leagues);
1240
1241
        if (cityId === CUSTOM_VALUE && leagueEntries.length === 0) {
1242
            html += `<p class="no-results">No teams selected yet — use the Customize panel above to pick some.</p>`;
1243
        }
1244
1245
        let emptyLeagues = [];
1246
1247
        for (const [league, data] of leagueEntries) {
1248
            const hasLive = data.live && data.live.length > 0;
1249
            const hasGames = data.games && data.games.length > 0;
1250
            const hasUpcoming = data.upcoming && data.upcoming.length > 0;
1251
1252
            if (!hasLive && !hasGames && !hasUpcoming) {
1253
                if (league !== 'FIFA') emptyLeagues.push(league);
1254
                continue;
1255
            }
1256
1257
            html += `<h2>\${escapeHTML(league)}</h2>`;
1258
1259
            // 1. Upcoming Games
1260
            if (hasUpcoming) {
1261
                dedupeGames(data.upcoming).forEach(({ game }) => {
1262
                    const title = displayTeamName(league, game.team_name, crossRegion);
1263
                    const details = `\${game.vsAt} \${game.opponent} `;
1264
1265
                    html += `
1266
                        <div class="game-card upcoming">
1267
                            <strong>\${escapeHTML(title)}</strong>
1268
                            <span class="game-details">\${escapeHTML(details)}(\${dateWithTooltip(game.date_str, game.date_raw, 'game-date')})</span>
1269
                        </div>
1270
                    `;
1271
                });
1272
            }
1273
1274
            // 2. Live Games
1275
            if (hasLive) {
1276
                dedupeGames(data.live).forEach(({ game }) => {
1277
                    const title = `\${displayTeamName(league, game.team_name, crossRegion)} — \${game.live_status}`;
1278
                    const details = `\${game.team_score}-\${game.opp_score} \${game.vsAt} \${game.opponent} (\${game.progress})`;
1279
1280
                    html += `
1281
                        <div class="game-card live">
1282
                            <span class="live-indicator">● LIVE</span>
1283
                            <strong>\${escapeHTML(title)}</strong>
1284
                            <span class="game-details">\${escapeHTML(details)}</span>
1285
                        </div>
1286
                    `;
1287
                });
1288
            }
1289
1290
            // 3. Completed Games
1291
            if (hasGames) {
1292
                dedupeGames(data.games).forEach(({ game, bothSides }) => {
1293
                    const title = `\${displayTeamName(league, game.team_name, crossRegion)} \${game.outcome}`;
1294
                    const scorePart = game.outcome === 'Postponed' ? '' : `\${game.team_score}-\${game.opp_score} `;
1295
                    const details = `— \${scorePart}\${game.vsAt} \${game.opponent} `;
1296
1297
                    // When both tracked teams played each other, collapse to one
1298
                    // card and use the neutral "tied" palette to signal it.
1299
                    let outcomeClass = '';
1300
                    if (bothSides) outcomeClass = ' both-sides';
1301
                    else if (game.outcome === 'Won') outcomeClass = ' win';
1302
                    else if (game.outcome === 'Lost') outcomeClass = ' loss';
1303
                    else if (game.outcome === 'Tied') outcomeClass = ' tie';
1304
                    else if (game.outcome === 'Postponed') outcomeClass = ' postponed';
1305
1306
                    html += `
1307
                        <div class="game-card\${outcomeClass}">
1308
                            <strong>\${escapeHTML(title)}</strong>
1309
                            <span class="game-details">\${escapeHTML(details)}(\${dateWithTooltip(game.date_str, game.date_raw, 'game-date')})</span>
1310
                        </div>
1311
                    `;
1312
                });
1313
            }
1314
        }
1315
1316
        if (emptyLeagues.length > 0) {
1317
            let emptyStr = '';
1318
            if (emptyLeagues.length === 1) {
1319
                emptyStr = emptyLeagues[0];
1320
            } else if (emptyLeagues.length === 2) {
1321
                emptyStr = emptyLeagues.join(' or ');
1322
            } else {
1323
                const last = emptyLeagues.pop();
1324
                emptyStr = emptyLeagues.join(', ') + ', or ' + last;
1325
            }
1326
            html += `<p class="no-results" style="margin-top: 0.5rem;">No recent or upcoming results available for \${escapeHTML(emptyStr)}.</p>`;
1327
        }
1328
1329
        html += renderMajorEventsHtml();
1330
        resultsContainer.innerHTML = html;
1331
    }
1332
1333
    citySelect.addEventListener('change', () => {
1334
        storeCity(citySelect.value);
1335
        updateUrl(citySelect.value);
1336
        updateCustomizeVisibility();
1337
        renderResults();
1338
    });
1339
1340
    teamSearch.addEventListener('input', filterChecklist);
1341
    selectAllShownBtn.addEventListener('click', toggleAllShown);
1342
    toggleCustomizeEditorBtn.addEventListener('click', () => setEditorCollapsed(!isEditorCollapsed()));
1343
1344
    // Tap-to-reveal relative time for touch devices (no hover). Tapping a
1345
    // date swaps it for its "time ago" label; tapping again (or tapping a
1346
    // different date) restores it. Recomputes fresh from data-raw each tap.
1347
    const REL_SELECTOR = '.game-date, .event-date-inner, #scores-updated-ts, #page-loaded-line';
1348
    let openRelEl = null;
1349
1350
    function restoreRel(el) {
1351
        if (el && el.classList.contains('rel-open')) {
1352
            el.textContent = el.dataset.origText;
1353
            el.classList.remove('rel-open');
1354
        }
1355
    }
1356
1357
    document.addEventListener('click', (e) => {
1358
        const el = e.target.closest(REL_SELECTOR);
1359
        if (openRelEl && openRelEl !== el) { restoreRel(openRelEl); openRelEl = null; }
1360
        if (!el) return;
1361
1362
        if (el.classList.contains('rel-open')) {
1363
            restoreRel(el);
1364
            openRelEl = null;
1365
            return;
1366
        }
1367
        const rel = relativeTime(el.dataset.raw || '');
1368
        if (!rel) return;
1369
        el.dataset.origText = el.textContent;
1370
        el.textContent = rel;
1371
        el.classList.add('rel-open');
1372
        openRelEl = el;
1373
    });
1374
1375
    citySelect.value = resolveInitialCity();
1376
1377
    if (getStoredCustomTeams().length === 0 && citySelect.value !== CUSTOM_VALUE && sportsData[citySelect.value]) {
1378
        const initialCityLeagues = sportsData[citySelect.value].leagues || {};
1379
        const seedKeys = [];
1380
        for (const team of allTeams) {
1381
            if (team.city_key === citySelect.value) seedKeys.push(teamKey(team));
1382
        }
1383
        if (seedKeys.length) storeCustomTeams(seedKeys);
1384
    }
1385
1386
    updateUrl(citySelect.value);
1387
    updateCustomizeVisibility();
1388
    renderResults();
1389
1390
    const PAGE_LOAD_STORAGE_KEY = 'sportsfan_page_loaded_at';
1391
    const pageLoadedAt = Date.now();
1392
1393
    try { window.localStorage.setItem(PAGE_LOAD_STORAGE_KEY, String(pageLoadedAt)); } catch (e) { }
1394
1395
    const pageLoadedLine = document.getElementById('page-loaded-line');
1396
    if (pageLoadedLine) {
1397
        const loadedDate = new Date(pageLoadedAt);
1398
        const formatted = new Intl.DateTimeFormat('en-US', {
1399
            weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
1400
            hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short'
1401
        }).format(loadedDate).replace(' at ', ', ');
1402
        pageLoadedLine.textContent = formatted;
1403
        pageLoadedLine.title = relativeTime(pageLoadedAt);
1404
        pageLoadedLine.dataset.raw = String(pageLoadedAt);
1405
    }
1406
1407
    // "Scores last updated" hover → relative time from the build timestamp
1408
    const scoresUpdatedTs = document.getElementById('scores-updated-ts');
1409
    if (scoresUpdatedTs) {
1410
        const rel = relativeTime(SCORES_UPDATED_AT);
1411
        if (rel) scoresUpdatedTs.title = rel;
1412
        scoresUpdatedTs.dataset.raw = String(SCORES_UPDATED_AT);
1413
    }
1414
1415
    function getChicagoParts(date) {
1416
        const parts = new Intl.DateTimeFormat('en-US', {
1417
            timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit',
1418
            hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
1419
        }).formatToParts(date);
1420
        const map = {};
1421
        parts.forEach(p => { map[p.type] = p.value; });
1422
        if (map.hour === '24') map.hour = '0';
1423
        return {
1424
            year: Number(map.year), month: Number(map.month), day: Number(map.day),
1425
            hour: Number(map.hour), minute: Number(map.minute), second: Number(map.second)
1426
        };
1427
    }
1428
1429
    function mostRecent3amCentral() {
1430
        const now = new Date();
1431
        const chicagoNow = getChicagoParts(now);
1432
1433
        function chicagoWallClockToUtc(year, month, day, hour) {
1434
            const guess = Date.UTC(year, month - 1, day, hour, 0, 0);
1435
            const guessChicagoParts = getChicagoParts(new Date(guess));
1436
            const guessAsUtc = Date.UTC(
1437
                guessChicagoParts.year, guessChicagoParts.month - 1, guessChicagoParts.day,
1438
                guessChicagoParts.hour, guessChicagoParts.minute, guessChicagoParts.second
1439
            );
1440
            return guess + (guess - guessAsUtc);
1441
        }
1442
1443
        const todayAt3amUtc = chicagoWallClockToUtc(chicagoNow.year, chicagoNow.month, chicagoNow.day, 3);
1444
1445
        if (todayAt3amUtc <= now.getTime()) return todayAt3amUtc;
1446
1447
        const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1448
        const chicagoYesterday = getChicagoParts(yesterday);
1449
        return chicagoWallClockToUtc(chicagoYesterday.year, chicagoYesterday.month, chicagoYesterday.day, 3);
1450
    }
1451
1452
    const staleBanner = document.getElementById('stale-banner');
1453
    const staleRefreshLink = document.getElementById('stale-refresh-link');
1454
1455
    function checkStaleness() {
1456
        if (pageLoadedAt < mostRecent3amCentral()) staleBanner.hidden = false;
1457
    }
1458
1459
    staleRefreshLink.addEventListener('click', (e) => {
1460
        e.preventDefault();
1461
        window.location.reload();
1462
    });
1463
1464
    document.addEventListener('visibilitychange', () => {
1465
        if (document.visibilityState === 'visible') checkStaleness();
1466
    });
1467
1468
    setInterval(checkStaleness, 5 * 60 * 1000); 
1469
1470
</script>
1471
</body>
1472
</html>
1473
HTML;
1474
1475
    return $indexTemplate;
1476
}
1477
1478
/**
1479
 * Write the generated index.php. Returns false if the write failed (e.g.
1480
 * the web server user can't write to the directory, or the file is locked
1481
 * on Windows) so callers can report it instead of falsely claiming success.
1482
 */
1483
function write_index_file(string $html, string $targetFile): bool
1484
{
1485
    $bytes = @file_put_contents($targetFile, $html);
1486
    if ($bytes === false) {
1487
        return false;
1488
    }
1489
    if (function_exists('opcache_invalidate')) {
1490
        opcache_invalidate($targetFile, true);
1491
    }
1492
    return true;
1493
}