1423 lines · 58.4 KB
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
            ];
412
        }
413
    }
414
    if (!empty($fifaGames) || !empty($fifaUpcoming)) {
415
        $allTeamsOut[] = [
416
            'name' => 'USA', 'league' => 'FIFA', 'abbr' => 'usa',
417
            'city_key' => 'all', 'city_label' => 'National',
418
            'city' => '', 'state' => 'United States', 'search' => 'USMNT',
419
        ];
420
    }
421
    $database['_all_teams'] = $allTeamsOut;
422
423
    return $database;
424
}
425
426
/**
427
 * Mutates a provided database in-place, adding live games extracted from scoreboards
428
 * while removing those same games from the upcoming array to prevent duplication.
429
 */
430
function apply_live_scoreboards(array &$database, array $liveRawByKey, array $CITIES, ?callable $log = null): void
431
{
432
    // Which league each build_live_step_list() key represents. We need this
433
    // because ESPN abbreviations are NOT unique across leagues — e.g. "phi"
434
    // is reused by the Eagles (NFL), 76ers (NBA), Phillies (MLB), and Flyers
435
    // (NHL). A single global abbr->team lookup would let later teams silently
436
    // overwrite earlier ones sharing the same city abbreviation. Since each
437
    // scoreboard fetch is already scoped to one league, we build a lookup
438
    // PER LEAGUE instead, so "phi" in the MLB scoreboard only ever resolves
439
    // against MLB teams.
440
    $keyToLeague = [
441
        'live_nfl'  => 'NFL',
442
        'live_mlb'  => 'MLB',
443
        'live_nhl'  => 'NHL',
444
        'live_nba'  => 'NBA',
445
        'live_wnba' => 'WNBA',
446
        'live_ncaam' => 'NCAAM',
447
        'live_ncaaw' => 'NCAAW',
448
        'live_fifa' => 'FIFA',
449
    ];
450
451
    $teamLookupByLeague = [];
452
    foreach ($CITIES as $cKey => $cData) {
453
        foreach ($cData['teams'] as $team) {
454
            $leagueKey = strtoupper($team['league']);
455
            $teamLookupByLeague[$leagueKey][strtolower($team['abbr'])] = [
456
                'city_key' => $cKey,
457
                'league'   => $leagueKey,
458
                'name'     => $team['name']
459
            ];
460
        }
461
    }
462
    // FIFA isn't in $CITIES (it's the national team, not a city team), and we
463
    // only ever care about the USA's match, so this scoping also naturally
464
    // filters the FIFA scoreboard down to just USA's game.
465
    $teamLookupByLeague['FIFA']['usa'] = ['city_key' => 'all', 'league' => 'FIFA', 'name' => 'USA'];
466
467
    foreach ($liveRawByKey as $key => $scoreboardData) {
468
        if (!$scoreboardData || empty($scoreboardData['events'])) {
469
            if ($log) $log("apply key=$key: no scoreboard data or no events");
470
            continue;
471
        }
472
473
        $league = $keyToLeague[$key] ?? null;
474
        if (!$league || empty($teamLookupByLeague[$league])) {
475
            if ($log) $log("apply key=$key: unknown league mapping — skipping");
476
            continue;
477
        }
478
        $teamLookup = $teamLookupByLeague[$league];
479
480
        $eventTotal = count($scoreboardData['events']);
481
        $inCount = 0;
482
        $matched = 0;
483
        $unmatched = [];
484
485
        foreach ($scoreboardData['events'] as $event) {
486
            $state = $event['status']['type']['state'] ?? '';
487
            if ($state !== 'in') continue; // only process live games
488
            $inCount++;
489
490
            $competition = $event['competitions'][0] ?? null;
491
            if (!$competition || empty($competition['competitors'])) continue;
492
493
            $comps = $competition['competitors'];
494
            if (count($comps) < 2) continue;
495
496
            // Evaluate for each competitor in our tracked list
497
            foreach ($comps as $idx => $competitor) {
498
                $abbr = strtolower($competitor['team']['abbreviation'] ?? '');
499
                if (!isset($teamLookup[$abbr])) {
500
                    $unmatched[$abbr] = true;
501
                    continue;
502
                }
503
                $matched++;
504
505
                $info = $teamLookup[$abbr];
506
                $opponent = $idx === 0 ? $comps[1] : $comps[0];
507
508
                $teamScore = (int) extract_score($competitor['score'] ?? 0);
509
                $oppScore  = (int) extract_score($opponent['score'] ?? 0);
510
511
                if ($teamScore > $oppScore) $liveStatus = 'Winning';
512
                elseif ($teamScore < $oppScore) $liveStatus = 'Losing';
513
                else $liveStatus = 'Tied';
514
515
                $progress = $competition['status']['type']['detail'] ?? 'In Progress';
516
517
                $record = [
518
                    'timestamp'  => strtotime($event['date']),
519
                    'team_name'  => $info['name'],
520
                    'label'      => $info['league'],
521
                    'outcome'    => 'live',
522
                    'live_status'=> $liveStatus,
523
                    'team_score' => $teamScore,
524
                    'opp_score'  => $oppScore,
525
                    'vsAt'       => ($competitor['homeAway'] ?? '') === 'home' ? 'vs' : '@',
526
                    'opponent'   => $opponent['team']['displayName'] ?? ($opponent['team']['name'] ?? 'Opponent'),
527
                    'progress'   => $progress,
528
                    'date_raw'   => $event['date'],
529
                    'game_id'    => $event['id'] ?? null,
530
                ];
531
532
                $applyLiveToBucket = function(&$leagueBucket) use ($record) {
533
                    if (!isset($leagueBucket['live'])) $leagueBucket['live'] = [];
534
                    $leagueBucket['live'][] = $record;
535
                    
536
                    // Drop from upcoming if it's currently live
537
                    if (isset($leagueBucket['upcoming'])) {
538
                        foreach ($leagueBucket['upcoming'] as $k => $up) {
539
                            if (strtolower($up['opponent']) === strtolower($record['opponent'])) {
540
                                unset($leagueBucket['upcoming'][$k]);
541
                            }
542
                        }
543
                        $leagueBucket['upcoming'] = array_values($leagueBucket['upcoming']);
544
                    }
545
                };
546
547
                // National teams (city_key === 'all', e.g. USA at the World
548
                // Cup) aren't tied to one region — aggregate_database() copies
549
                // their completed/upcoming games into EVERY city's FIFA bucket,
550
                // so a live national game must fan out the same way. City teams
551
                // write only to their own region.
552
                $isNational = ($info['city_key'] === 'all');
553
                $wroteCityCount = 0;
554
555
                if ($isNational) {
556
                    foreach (array_keys($database) as $dbKey) {
557
                        if ($dbKey === 'all') continue; // 'all' handled separately below
558
                        if (!isset($database[$dbKey]['leagues'][$info['league']])) continue;
559
                        $applyLiveToBucket($database[$dbKey]['leagues'][$info['league']]);
560
                        $wroteCityCount++;
561
                    }
562
                } elseif (isset($database[$info['city_key']]['leagues'][$info['league']])) {
563
                    $applyLiveToBucket($database[$info['city_key']]['leagues'][$info['league']]);
564
                    $wroteCityCount = 1;
565
                }
566
567
                $wroteAll = false;
568
                if (isset($database['all']['leagues'][$info['league']])) {
569
                    $applyLiveToBucket($database['all']['leagues'][$info['league']]);
570
                    $wroteAll = true;
571
                }
572
573
                if ($log) {
574
                    $log(sprintf(
575
                        'apply key=%s matched %s (%s) abbr=%s %s %d-%d vs %s [%s] -> %s region bucket(s): %d written, all bucket: %s',
576
                        $key,
577
                        $info['name'],
578
                        $info['league'],
579
                        $abbr,
580
                        $liveStatus,
581
                        $teamScore,
582
                        $oppScore,
583
                        $record['opponent'],
584
                        $progress,
585
                        $isNational ? 'national (all regions)' : $info['city_key'],
586
                        $wroteCityCount,
587
                        $wroteAll ? 'written' : 'MISSING BUCKET'
588
                    ));
589
                }
590
            }
591
        }
592
593
        if ($log) {
594
            $log(sprintf(
595
                'apply key=%s summary: league=%s events=%d live(in)=%d competitorsMatched=%d unmatchedAbbrs=[%s]',
596
                $key,
597
                $league,
598
                $eventTotal,
599
                $inCount,
600
                $matched,
601
                implode(',', array_keys($unmatched))
602
            ));
603
        }
604
    }
605
}
606
607
function render_index_html(array $database, array $CITIES, string $timestamp): string
608
{
609
    $jsonDatabase = json_encode($database, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
610
    $updatedAtMs  = time() * 1000; // build time, for the "last updated" hover tooltip
611
612
    $optionsHtml = '';
613
    foreach ($CITIES as $key => $city) {
614
        $selected = ($key === 'chicago_wisconsin') ? ' selected' : '';
615
        $optionsHtml .= '                <option value="' . htmlspecialchars($key) . '"' . $selected . '>' . htmlspecialchars($city['label']) . '</option>' . "\n";
616
    }
617
    $optionsHtml .= '                <option value="all">All Cities</option>' . "\n";
618
    $optionsHtml .= '                <option value="__custom__">Customize&hellip;</option>' . "\n";
619
620
    $indexTemplate = <<<HTML
621
<?php
622
// AUTO-GENERATED at {$timestamp}
623
header("Cache-Control: public, max-age=300");
624
?>
625
<!DOCTYPE html>
626
<html lang="en">
627
<head>
628
<meta charset="UTF-8">
629
<meta name="viewport" content="width=device-width, initial-scale=1.0">
630
<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.">
631
<title>Casual Fan Sports Report</title>
632
<style>
633
    :root {
634
        color-scheme: dark light;
635
        --bg-color: #f8fafc;
636
        --card-bg: #ffffff;
637
        --text-primary: #0f172a;
638
        --text-secondary: #475569;
639
        --border: #e2e8f0;
640
        --radius: 8px;
641
        --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
642
        
643
        --upcoming-bg: #f0f9ff;
644
        --upcoming-border: #bae6fd;
645
        --win-bg: #f0fdf4;
646
        --win-border: #bbf7d0;
647
        --loss-bg: #fef2f2;
648
        --loss-border: #fecaca;
649
        --neutral-bg: #fefce8;
650
        --neutral-border: #fde68a;
651
    }
652
653
    @media (prefers-color-scheme: dark) {
654
        :root {
655
            --bg-color: #0f172a;
656
            --card-bg: #1e293b;
657
            --text-primary: #f8fafc;
658
            --text-secondary: #bccbe1;
659
            --border: #334155;
660
            --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.5);
661
            
662
            --upcoming-bg: #082f49;
663
            --upcoming-border: #0369a1;
664
            --win-bg: #14532d;
665
            --win-border: #166534;
666
            --loss-bg: #7f1d1d;
667
            --loss-border: #991b1b;
668
            --neutral-bg: #422006;
669
            --neutral-border: #a16207;
670
        }
671
    }
672
673
    body {
674
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
675
        background-color: var(--bg-color);
676
        color: var(--text-primary);
677
        line-height: 1.3;
678
        margin: 0;
679
        padding: 0.75rem 0.75rem 1.5rem;
680
        font-size: 15px;
681
    }
682
    .container { max-width: 650px; margin: 0 auto; }
683
    h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -0.025em; margin: 0 0 0.5rem 0; }
684
685
    .selector-wrapper {
686
        background: var(--card-bg); padding: 0.5rem 0.625rem; border-radius: var(--radius);
687
        border: 1px solid var(--border); box-shadow: var(--shadow);
688
        margin-bottom: 0.75rem; display: flex; flex-direction: row; align-items: center; gap: 0.5rem;
689
    }
690
    label { font-weight: 600; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; }
691
    select {
692
        padding: 0.3rem 0.4rem; border-radius: 6px; border: 1px solid var(--border);
693
        background-color: var(--card-bg); font-size: 0.9rem; color: var(--text-primary);
694
        width: 100%; cursor: pointer;
695
    }
696
697
    h2 {
698
        font-size: 0.7rem; color: var(--text-secondary); margin: 0.6rem 0 0.3rem 0;
699
        text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border);
700
        padding-bottom: 0.15rem;
701
    }
702
    h2:first-child { margin-top: 0; }
703
    .game-card {
704
        background: var(--card-bg); padding: 0.35rem 0.6rem; border-radius: 6px;
705
        border: 1px solid var(--border); margin-bottom: 0.3rem;
706
        display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.4rem;
707
        font-size: 0.85rem;
708
    }
709
    
710
    .game-card.upcoming { background: var(--upcoming-bg); border-color: var(--upcoming-border); }
711
    .game-card.win { background: var(--win-bg); border-color: var(--win-border); }
712
    .game-card.loss { background: var(--loss-bg); border-color: var(--loss-border); }
713
    .game-card.tie,
714
    .game-card.postponed,
715
    .game-card.both-sides { background: var(--neutral-bg); border-color: var(--neutral-border); }
716
717
    /* Dates carry a "time ago" tooltip on hover */
718
    .game-date, .event-date-inner { cursor: help; border-bottom: 1px dotted var(--border); }
719
    
720
    /* Live Game Styles */
721
    .game-card.live { background: #fff1f2; border-color: #fecdd3; border-left: 3px solid #e11d48; }
722
    @media (prefers-color-scheme: dark) {
723
        .game-card.live { background: #4c0519; border-color: #881337; border-left: 3px solid #f43f5e; }
724
    }
725
    .live-indicator { 
726
        color: #e11d48; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; 
727
        letter-spacing: 0.05em; animation: pulse 2s infinite; display: inline-block; margin-right: 0.35rem;
728
    }
729
    @media (prefers-color-scheme: dark) { .live-indicator { color: #f43f5e; } }
730
    @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
731
    
732
    .game-card strong { color: var(--text-primary); font-weight: 600; }
733
    .game-details { color: var(--text-secondary); }
734
    .no-results { color: var(--text-secondary); font-style: italic; padding: 0.15rem 0; margin: 0 0 0.3rem 0; font-size: 0.85rem; }
735
    
736
    .last-updated { font-size: 0.7rem; color: var(--text-secondary); text-align: center; margin-top: 1rem; }
737
    .page-loaded { font-size: 0.7rem; color: var(--text-secondary); text-align: center; margin-top: 0.15rem; }
738
    .update-schedule { font-size: 0.7rem; color: var(--text-secondary); text-align: center; margin-top: 0.15rem; }
739
740
    /* Major events (Super Bowl, Finals, World Cup, etc.) */
741
    .major-events { margin-top: 0.75rem; }
742
    .major-event-card {
743
        background: var(--card-bg); padding: 0.45rem 0.65rem; border-radius: 6px;
744
        border: 1px solid var(--border); margin-bottom: 0.3rem; font-size: 0.85rem;
745
        border-left: 3px solid #eab308;
746
    }
747
    .major-event-card .event-label {
748
        font-weight: 700; text-transform: uppercase; font-size: 0.65rem;
749
        letter-spacing: 0.05em; color: #b45309; display: block; margin-bottom: 0.15rem;
750
    }
751
    .major-event-card .matchup { color: var(--text-primary); font-weight: 600; }
752
    .major-event-card .matchup .winner { color: #15803d; }
753
    .major-event-card .event-date { color: var(--text-secondary); font-size: 0.75rem; margin-left: 0.35rem; }
754
755
    /* Customize panel */
756
    .customize-panel {
757
        background: var(--card-bg); padding: 0.6rem 0.65rem; border-radius: var(--radius);
758
        border: 1px solid var(--border); box-shadow: var(--shadow); margin-bottom: 0.75rem;
759
    }
760
    .customize-header-row {
761
        display: flex; align-items: center; justify-content: space-between;
762
        margin-bottom: 0.5rem;
763
    }
764
    .customize-title {
765
        font-weight: 700; font-size: 0.75rem; color: var(--text-secondary);
766
        text-transform: uppercase; letter-spacing: 0.05em;
767
    }
768
    .toggle-editor-btn {
769
        padding: 0.3rem 0.55rem; border-radius: 6px; border: 1px solid var(--border);
770
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.72rem;
771
        cursor: pointer; white-space: nowrap; font-weight: 600;
772
    }
773
    .toggle-editor-btn:hover { background-color: var(--border); }
774
    .customize-collapsed-hint {
775
        font-size: 0.75rem; color: var(--text-secondary); font-style: italic; margin: 0;
776
    }
777
    .customize-panel .customize-row {
778
        display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;
779
    }
780
    .customize-search {
781
        flex: 1; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border);
782
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.85rem;
783
    }
784
    .select-all-btn {
785
        padding: 0.35rem 0.6rem; border-radius: 6px; border: 1px solid var(--border);
786
        background-color: var(--bg-color); color: var(--text-primary); font-size: 0.75rem;
787
        cursor: pointer; white-space: nowrap; font-weight: 600;
788
    }
789
    .select-all-btn:hover { background-color: var(--border); }
790
    .team-checklist {
791
        max-height: 260px; overflow-y: auto; border: 1px solid var(--border);
792
        border-radius: 6px; padding: 0.35rem 0.5rem;
793
    }
794
    .league-group-header {
795
        position: sticky; top: 0; z-index: 1; background: var(--card-bg);
796
        font-weight: 700; font-size: 0.68rem; color: var(--text-secondary);
797
        text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.2rem 0;
798
        padding: 0.4rem 0 0.15rem 0; border-bottom: 1px solid var(--border);
799
    }
800
    .league-group-header:first-child { padding-top: 0.1rem; }
801
    .league-group-header.hidden { display: none; }
802
    .team-check-item {
803
        display: flex; align-items: center; gap: 0.4rem; padding: 0.2rem 0;
804
        font-size: 0.82rem;
805
    }
806
    .team-check-item input { cursor: pointer; }
807
    .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; }
808
    .team-check-item .team-city-tag {
809
        font-size: 0.68rem; color: var(--text-secondary); font-weight: 400;
810
    }
811
    .team-check-item.hidden { display: none; }
812
    .customize-empty-hint { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; padding: 0.3rem 0; }
813
814
    .stale-banner {
815
        background: var(--neutral-bg); border: 1px solid var(--neutral-border);
816
        color: var(--text-primary); border-radius: var(--radius);
817
        padding: 0.5rem 0.7rem; margin-bottom: 0.75rem; font-size: 0.85rem;
818
    }
819
    .stale-banner a { color: var(--text-primary); font-weight: 600; }
820
    .stale-banner a:hover { text-decoration: none; }
821
822
    @media (max-width: 480px) {
823
        body { padding: 0.5rem 0.5rem 1rem; font-size: 14px; }
824
        h1 { font-size: 1.1rem; }
825
        .game-card { font-size: 0.8rem; padding: 0.3rem 0.5rem; }
826
        h3 { font-size: 0.65rem; }
827
    }
828
</style>
829
</head>
830
<body>
831
832
<main class="container">
833
    <h1>Casual Fan Sports Report</h1>
834
835
    <div id="stale-banner" class="stale-banner" hidden>
836
        New data is available. <a href="#" id="stale-refresh-link">Refresh to see the latest scores</a>.
837
    </div>
838
839
    <div class="selector-wrapper">
840
        <label for="city">City</label>
841
        <select id="city">
842
{$optionsHtml}
843
        </select>
844
    </div>
845
846
    <div id="customize-panel" class="customize-panel" hidden>
847
        <div class="customize-header-row">
848
            <span class="customize-title">Customize your teams</span>
849
            <button type="button" id="toggle-customize-editor" class="toggle-editor-btn">Hide picker</button>
850
        </div>
851
        <div id="customize-editor">
852
            <div class="customize-row">
853
                <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;)">
854
                <button type="button" id="select-all-shown" class="select-all-btn">Select all shown</button>
855
            </div>
856
            <div id="team-checklist" class="team-checklist"></div>
857
        </div>
858
        <p id="customize-collapsed-hint" class="customize-collapsed-hint" hidden>Picker hidden — your selections are still active.</p>
859
    </div>
860
861
    <div id="results-container"></div>
862
863
    <div class="last-updated">Scores last updated: <span id="scores-updated-ts">{$timestamp}</span></div>
864
    <div class="page-loaded" id="page-loaded-line"></div>
865
    <div class="update-schedule">Live scores updated every 30 minutes, Results updated at 3 am</div>
866
867
</main>
868
869
<script>
870
    const sportsData = {$jsonDatabase};
871
    const majorEvents = sportsData._major_events || [];
872
    const allTeams = sportsData._all_teams || [];
873
    const SCORES_UPDATED_AT = {$updatedAtMs};
874
875
    const citySelect = document.getElementById('city');
876
    const resultsContainer = document.getElementById('results-container');
877
    const customizePanel = document.getElementById('customize-panel');
878
    const teamChecklist = document.getElementById('team-checklist');
879
    const teamSearch = document.getElementById('team-search');
880
    const selectAllShownBtn = document.getElementById('select-all-shown');
881
    const customizeEditor = document.getElementById('customize-editor');
882
    const toggleCustomizeEditorBtn = document.getElementById('toggle-customize-editor');
883
    const customizeCollapsedHint = document.getElementById('customize-collapsed-hint');
884
885
    const CITY_STORAGE_KEY = 'sportsfan_selected_city';
886
    const CUSTOM_TEAMS_STORAGE_KEY = 'sportsfan_custom_teams';
887
    const CUSTOM_EDITOR_COLLAPSED_KEY = 'sportsfan_custom_editor_collapsed';
888
    const CUSTOM_VALUE = '__custom__';
889
890
    function getUrlCity() {
891
        const params = new URLSearchParams(window.location.search);
892
        return params.get('city');
893
    }
894
895
    function getStoredCustomTeams() {
896
        try {
897
            const raw = window.localStorage.getItem(CUSTOM_TEAMS_STORAGE_KEY);
898
            const parsed = raw ? JSON.parse(raw) : [];
899
            return Array.isArray(parsed) ? parsed : [];
900
        } catch (e) {
901
            return [];
902
        }
903
    }
904
905
    function storeCustomTeams(teamKeys) {
906
        try {
907
            window.localStorage.setItem(CUSTOM_TEAMS_STORAGE_KEY, JSON.stringify(teamKeys));
908
        } catch (e) {
909
        }
910
    }
911
912
    function teamKey(team) {
913
        return `\${team.league}::\${team.abbr}::\${team.name}`;
914
    }
915
916
    function buildCustomCityData(selectedKeys) {
917
        const selectedSet = new Set(selectedKeys);
918
        const leagues = {};
919
920
        for (const team of allTeams) {
921
            if (!selectedSet.has(teamKey(team))) continue;
922
923
            const cityBucket = sportsData[team.city_key];
924
            if (!cityBucket) continue;
925
926
            const leagueData = cityBucket.leagues[team.league];
927
            if (!leagueData) continue;
928
929
            if (!leagues[team.league]) {
930
                leagues[team.league] = { latest_timestamp: 0, live: [], games: [], upcoming: [] };
931
            }
932
933
            const teamLive = (leagueData.live || []).filter(g => g.team_name === team.name);
934
            const teamGames = (leagueData.games || []).filter(g => g.team_name === team.name);
935
            const teamUpcoming = (leagueData.upcoming || []).filter(g => g.team_name === team.name);
936
937
            leagues[team.league].live.push(...teamLive);
938
            leagues[team.league].games.push(...teamGames);
939
            leagues[team.league].upcoming.push(...teamUpcoming);
940
        }
941
942
        for (const key of Object.keys(leagues)) {
943
            leagues[key].games.sort((a, b) => b.timestamp - a.timestamp);
944
            leagues[key].upcoming.sort((a, b) => a.timestamp - b.timestamp);
945
            leagues[key].latest_timestamp = leagues[key].games.length
946
                ? leagues[key].games[0].timestamp
947
                : 0;
948
        }
949
950
        return { label: 'Customize', is_all: false, leagues };
951
    }
952
953
    function getStoredCity() {
954
        try { return window.localStorage.getItem(CITY_STORAGE_KEY); } catch (e) { return null; }
955
    }
956
957
    function storeCity(cityId) {
958
        try { window.localStorage.setItem(CITY_STORAGE_KEY, cityId); } catch (e) { }
959
    }
960
961
    function updateUrl(cityId) {
962
        const url = new URL(window.location.href);
963
        url.searchParams.set('city', cityId);
964
        window.history.replaceState({}, '', url);
965
    }
966
967
    function resolveInitialCity() {
968
        const urlCity = getUrlCity();
969
        if (urlCity && (sportsData[urlCity] || urlCity === CUSTOM_VALUE)) return urlCity;
970
971
        const storedCity = getStoredCity();
972
        if (storedCity && (sportsData[storedCity] || storedCity === CUSTOM_VALUE)) return storedCity;
973
974
        return citySelect.value;
975
    }
976
977
    function matchesSearch(team, query) {
978
        if (!query) return true;
979
        const q = query.toLowerCase();
980
        const haystack = [
981
            team.name, team.league, team.city_label,
982
            team.city, team.state, team.search
983
        ].filter(Boolean).join(' ').toLowerCase();
984
        return haystack.includes(q);
985
    }
986
987
    function renderTeamChecklist() {
988
        const storedKeys = new Set(getStoredCustomTeams());
989
        const query = teamSearch.value.trim();
990
991
        const sortedTeams = [...allTeams].sort((a, b) => {
992
            if (a.league !== b.league) return a.league.localeCompare(b.league);
993
            return a.name.localeCompare(b.name);
994
        });
995
996
        let html = '';
997
        let currentLeague = null;
998
999
        for (const team of sortedTeams) {
1000
            const key = teamKey(team);
1001
            const visible = matchesSearch(team, query);
1002
            const checked = storedKeys.has(key) ? ' checked' : '';
1003
            const inputId = `team-check-\${escapeHTML(key)}`;
1004
1005
            if (team.league !== currentLeague) {
1006
                currentLeague = team.league;
1007
                html += `<div class="league-group-header" data-league="\${escapeHTML(currentLeague)}">\${escapeHTML(currentLeague)}</div>`;
1008
            }
1009
1010
            html += `
1011
                <div class="team-check-item\${visible ? '' : ' hidden'}" data-team-key="\${escapeHTML(key)}" data-league="\${escapeHTML(team.league)}">
1012
                    <input type="checkbox" id="\${inputId}"\${checked} data-team-key="\${escapeHTML(key)}">
1013
                    <label for="\${inputId}">\${escapeHTML(team.city ? team.city + ' ' + team.name : team.name)} <span class="team-city-tag">- \${escapeHTML(team.city_label)}</span></label>
1014
                </div>
1015
            `;
1016
        }
1017
1018
        if (!sortedTeams.length) {
1019
            html = `<p class="customize-empty-hint">No teams available.</p>`;
1020
        }
1021
        teamChecklist.innerHTML = html;
1022
        teamChecklist.querySelectorAll('input[type="checkbox"]').forEach(input => {
1023
            input.addEventListener('change', onTeamCheckboxChange);
1024
        });
1025
        updateLeagueHeaderVisibility();
1026
        updateSelectAllShownLabel();
1027
    }
1028
1029
    function updateLeagueHeaderVisibility() {
1030
        const headers = Array.from(teamChecklist.querySelectorAll('.league-group-header'));
1031
        const items = Array.from(teamChecklist.querySelectorAll('.team-check-item'));
1032
1033
        headers.forEach(header => {
1034
            const anyVisible = items.some(item =>
1035
                item.dataset.league === header.dataset.league && !item.classList.contains('hidden')
1036
            );
1037
            header.classList.toggle('hidden', !anyVisible);
1038
        });
1039
    }
1040
1041
    function getCheckedKeysFromDom() {
1042
        return Array.from(teamChecklist.querySelectorAll('input[type="checkbox"]:checked'))
1043
            .map(input => input.dataset.teamKey);
1044
    }
1045
1046
    function onTeamCheckboxChange() {
1047
        storeCustomTeams(getCheckedKeysFromDom());
1048
        updateSelectAllShownLabel();
1049
        if (citySelect.value === CUSTOM_VALUE) renderResults();
1050
    }
1051
1052
    function filterChecklist() {
1053
        const query = teamSearch.value.trim();
1054
        teamChecklist.querySelectorAll('.team-check-item').forEach(item => {
1055
            const key = item.dataset.teamKey;
1056
            const team = allTeams.find(t => teamKey(t) === key);
1057
            const visible = team ? matchesSearch(team, query) : true;
1058
            item.classList.toggle('hidden', !visible);
1059
        });
1060
        updateLeagueHeaderVisibility();
1061
        updateSelectAllShownLabel();
1062
    }
1063
1064
    function getShownInputs() {
1065
        return Array.from(teamChecklist.querySelectorAll('.team-check-item:not(.hidden) input[type="checkbox"]'));
1066
    }
1067
1068
    function allShownChecked() {
1069
        const shown = getShownInputs();
1070
        return shown.length > 0 && shown.every(input => input.checked);
1071
    }
1072
1073
    function updateSelectAllShownLabel() {
1074
        selectAllShownBtn.textContent = allShownChecked() ? 'Deselect all shown' : 'Select all shown';
1075
    }
1076
1077
    function toggleAllShown() {
1078
        const shouldCheck = !allShownChecked();
1079
        getShownInputs().forEach(input => { input.checked = shouldCheck; });
1080
        storeCustomTeams(getCheckedKeysFromDom());
1081
        updateSelectAllShownLabel();
1082
        if (citySelect.value === CUSTOM_VALUE) renderResults();
1083
    }
1084
1085
    function isEditorCollapsed() {
1086
        try { return window.localStorage.getItem(CUSTOM_EDITOR_COLLAPSED_KEY) === '1'; } catch (e) { return false; }
1087
    }
1088
1089
    function setEditorCollapsed(collapsed) {
1090
        try { window.localStorage.setItem(CUSTOM_EDITOR_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch (e) { }
1091
        customizeEditor.hidden = collapsed;
1092
        customizeCollapsedHint.hidden = !collapsed;
1093
        toggleCustomizeEditorBtn.textContent = collapsed ? 'Edit teams' : 'Hide picker';
1094
    }
1095
1096
    function updateCustomizeVisibility() {
1097
        const isCustom = citySelect.value === CUSTOM_VALUE;
1098
        customizePanel.hidden = !isCustom;
1099
        if (isCustom) {
1100
            renderTeamChecklist();
1101
            setEditorCollapsed(isEditorCollapsed());
1102
        }
1103
    }
1104
1105
    function escapeHTML(str) {
1106
        return String(str).replace(/[&<>'"]/g,
1107
            tag => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[tag] || tag)
1108
        );
1109
    }
1110
1111
    // Human "time ago"/"in …" string for a hover tooltip. Accepts an ISO
1112
    // date string or an epoch (ms). Returns '' for anything unparseable.
1113
    function relativeTime(input) {
1114
        const then = (typeof input === 'number') ? input : Date.parse(input);
1115
        if (!then || isNaN(then)) return '';
1116
        const diffMs = then - Date.now();
1117
        const future = diffMs > 0;
1118
        const s = Math.abs(diffMs) / 1000;
1119
        if (s < 45) return 'just now';
1120
        const units = [
1121
            ['year', 31536000], ['month', 2592000], ['week', 604800],
1122
            ['day', 86400], ['hour', 3600], ['minute', 60]
1123
        ];
1124
        for (const [name, secs] of units) {
1125
            if (s >= secs) {
1126
                const n = Math.round(s / secs);
1127
                const label = `\${n} \${name}\${n === 1 ? '' : 's'}`;
1128
                return future ? `in \${label}` : `\${label} ago`;
1129
            }
1130
        }
1131
        return 'just now';
1132
    }
1133
1134
    // Wrap a baked-in date string in a span whose hover tooltip is the
1135
    // relative time computed from its raw ISO date.
1136
    function dateWithTooltip(dateStr, dateRaw, cls) {
1137
        const rel = relativeTime(dateRaw);
1138
        const titleAttr = rel ? ` title="\${escapeHTML(rel)}"` : '';
1139
        return `<span class="\${cls}"\${titleAttr}>\${escapeHTML(dateStr)}</span>`;
1140
    }
1141
1142
    // Collapse two perspectives of the same game (both tracked teams played
1143
    // each other) into one entry. Uses the stable ESPN game_id when present,
1144
    // falling back to a date + sorted-teams composite key. Marks bothSides
1145
    // and prefers the home-team ('vs') perspective for a natural read.
1146
    function dedupeGames(records) {
1147
        const seen = new Map();
1148
        const out = [];
1149
        for (const g of records) {
1150
            const key = (g.game_id != null && g.game_id !== '')
1151
                ? `id:\${g.game_id}`
1152
                : `k:\${g.date_raw}::\${[String(g.team_name), String(g.opponent)].sort().join('|')}`;
1153
            if (seen.has(key)) {
1154
                const entry = out[seen.get(key)];
1155
                entry.bothSides = true;
1156
                if (entry.game.vsAt !== 'vs' && g.vsAt === 'vs') entry.game = g;
1157
            } else {
1158
                seen.set(key, out.length);
1159
                out.push({ game: g, bothSides: false });
1160
            }
1161
        }
1162
        return out;
1163
    }
1164
1165
    function renderMajorEventsHtml() {
1166
        if (!majorEvents.length) return '';
1167
1168
        let html = '<h2>Championships &amp; Major Events</h2><div class="major-events">';
1169
        majorEvents.forEach(evt => {
1170
            const aClass = evt.team_a_winner ? ' winner' : '';
1171
            const bClass = evt.team_b_winner ? ' winner' : '';
1172
1173
            html += `
1174
                <div class="major-event-card">
1175
                    <span class="event-label">\${escapeHTML(evt.event_label)}</span>
1176
                    <span class="matchup">
1177
                        <span class="\${aClass.trim()}">\${escapeHTML(evt.team_a)}\${evt.is_final ? ' ' + evt.team_a_score : ''}</span>
1178
                        \${evt.is_final ? '–' : 'vs'}
1179
                        <span class="\${bClass.trim()}">\${escapeHTML(evt.team_b)}\${evt.is_final ? ' ' + evt.team_b_score : ''}</span>
1180
                    </span>
1181
                    <span class="event-date">(\${dateWithTooltip(evt.date_str, evt.date_raw, 'event-date-inner')})</span>
1182
                </div>
1183
            `;
1184
        });
1185
        html += '</div>';
1186
        return html;
1187
    }
1188
1189
    function renderResults() {
1190
        const cityId = citySelect.value;
1191
        resultsContainer.innerHTML = ''; 
1192
1193
        if (!cityId) return;
1194
1195
        const cityData = cityId === CUSTOM_VALUE
1196
            ? buildCustomCityData(getStoredCustomTeams())
1197
            : sportsData[cityId];
1198
1199
        if (!cityData) return;
1200
1201
        let html = '';
1202
        const leagueEntries = Object.entries(cityData.leagues);
1203
1204
        if (cityId === CUSTOM_VALUE && leagueEntries.length === 0) {
1205
            html += `<p class="no-results">No teams selected yet — use the Customize panel above to pick some.</p>`;
1206
        }
1207
1208
        let emptyLeagues = [];
1209
1210
        for (const [league, data] of leagueEntries) {
1211
            const hasLive = data.live && data.live.length > 0;
1212
            const hasGames = data.games && data.games.length > 0;
1213
            const hasUpcoming = data.upcoming && data.upcoming.length > 0;
1214
1215
            if (!hasLive && !hasGames && !hasUpcoming) {
1216
                if (league !== 'FIFA') emptyLeagues.push(league);
1217
                continue;
1218
            }
1219
1220
            html += `<h2>\${escapeHTML(league)}</h2>`;
1221
1222
            // 1. Upcoming Games
1223
            if (hasUpcoming) {
1224
                dedupeGames(data.upcoming).forEach(({ game }) => {
1225
                    const title = `\${game.team_name}`;
1226
                    const details = `\${game.vsAt} \${game.opponent} `;
1227
1228
                    html += `
1229
                        <div class="game-card upcoming">
1230
                            <strong>\${escapeHTML(title)}</strong>
1231
                            <span class="game-details">\${escapeHTML(details)}(\${dateWithTooltip(game.date_str, game.date_raw, 'game-date')})</span>
1232
                        </div>
1233
                    `;
1234
                });
1235
            }
1236
1237
            // 2. Live Games
1238
            if (hasLive) {
1239
                dedupeGames(data.live).forEach(({ game }) => {
1240
                    const title = `\${game.team_name} — \${game.live_status}`;
1241
                    const details = `\${game.team_score}-\${game.opp_score} \${game.vsAt} \${game.opponent} (\${game.progress})`;
1242
1243
                    html += `
1244
                        <div class="game-card live">
1245
                            <span class="live-indicator">● LIVE</span>
1246
                            <strong>\${escapeHTML(title)}</strong>
1247
                            <span class="game-details">\${escapeHTML(details)}</span>
1248
                        </div>
1249
                    `;
1250
                });
1251
            }
1252
1253
            // 3. Completed Games
1254
            if (hasGames) {
1255
                dedupeGames(data.games).forEach(({ game, bothSides }) => {
1256
                    const title = `\${game.team_name} \${game.outcome}`;
1257
                    const scorePart = game.outcome === 'Postponed' ? '' : `\${game.team_score}-\${game.opp_score} `;
1258
                    const details = `— \${scorePart}\${game.vsAt} \${game.opponent} `;
1259
1260
                    // When both tracked teams played each other, collapse to one
1261
                    // card and use the neutral "tied" palette to signal it.
1262
                    let outcomeClass = '';
1263
                    if (bothSides) outcomeClass = ' both-sides';
1264
                    else if (game.outcome === 'Won') outcomeClass = ' win';
1265
                    else if (game.outcome === 'Lost') outcomeClass = ' loss';
1266
                    else if (game.outcome === 'Tied') outcomeClass = ' tie';
1267
                    else if (game.outcome === 'Postponed') outcomeClass = ' postponed';
1268
1269
                    html += `
1270
                        <div class="game-card\${outcomeClass}">
1271
                            <strong>\${escapeHTML(title)}</strong>
1272
                            <span class="game-details">\${escapeHTML(details)}(\${dateWithTooltip(game.date_str, game.date_raw, 'game-date')})</span>
1273
                        </div>
1274
                    `;
1275
                });
1276
            }
1277
        }
1278
1279
        if (emptyLeagues.length > 0) {
1280
            let emptyStr = '';
1281
            if (emptyLeagues.length === 1) {
1282
                emptyStr = emptyLeagues[0];
1283
            } else if (emptyLeagues.length === 2) {
1284
                emptyStr = emptyLeagues.join(' or ');
1285
            } else {
1286
                const last = emptyLeagues.pop();
1287
                emptyStr = emptyLeagues.join(', ') + ', or ' + last;
1288
            }
1289
            html += `<p class="no-results" style="margin-top: 0.5rem;">No recent or upcoming results available for \${escapeHTML(emptyStr)}.</p>`;
1290
        }
1291
1292
        html += renderMajorEventsHtml();
1293
        resultsContainer.innerHTML = html;
1294
    }
1295
1296
    citySelect.addEventListener('change', () => {
1297
        storeCity(citySelect.value);
1298
        updateUrl(citySelect.value);
1299
        updateCustomizeVisibility();
1300
        renderResults();
1301
    });
1302
1303
    teamSearch.addEventListener('input', filterChecklist);
1304
    selectAllShownBtn.addEventListener('click', toggleAllShown);
1305
    toggleCustomizeEditorBtn.addEventListener('click', () => setEditorCollapsed(!isEditorCollapsed()));
1306
1307
    citySelect.value = resolveInitialCity();
1308
1309
    if (getStoredCustomTeams().length === 0 && citySelect.value !== CUSTOM_VALUE && sportsData[citySelect.value]) {
1310
        const initialCityLeagues = sportsData[citySelect.value].leagues || {};
1311
        const seedKeys = [];
1312
        for (const team of allTeams) {
1313
            if (team.city_key === citySelect.value) seedKeys.push(teamKey(team));
1314
        }
1315
        if (seedKeys.length) storeCustomTeams(seedKeys);
1316
    }
1317
1318
    updateUrl(citySelect.value);
1319
    updateCustomizeVisibility();
1320
    renderResults();
1321
1322
    const PAGE_LOAD_STORAGE_KEY = 'sportsfan_page_loaded_at';
1323
    const pageLoadedAt = Date.now();
1324
1325
    try { window.localStorage.setItem(PAGE_LOAD_STORAGE_KEY, String(pageLoadedAt)); } catch (e) { }
1326
1327
    const pageLoadedLine = document.getElementById('page-loaded-line');
1328
    if (pageLoadedLine) {
1329
        const loadedDate = new Date(pageLoadedAt);
1330
        const formatted = new Intl.DateTimeFormat('en-US', {
1331
            weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
1332
            hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short'
1333
        }).format(loadedDate);
1334
        pageLoadedLine.textContent = `Page loaded: \${formatted}`;
1335
        pageLoadedLine.title = relativeTime(pageLoadedAt);
1336
    }
1337
1338
    // "Scores last updated" hover → relative time from the build timestamp
1339
    const scoresUpdatedTs = document.getElementById('scores-updated-ts');
1340
    if (scoresUpdatedTs) {
1341
        const rel = relativeTime(SCORES_UPDATED_AT);
1342
        if (rel) scoresUpdatedTs.title = rel;
1343
    }
1344
1345
    function getChicagoParts(date) {
1346
        const parts = new Intl.DateTimeFormat('en-US', {
1347
            timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit',
1348
            hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
1349
        }).formatToParts(date);
1350
        const map = {};
1351
        parts.forEach(p => { map[p.type] = p.value; });
1352
        if (map.hour === '24') map.hour = '0';
1353
        return {
1354
            year: Number(map.year), month: Number(map.month), day: Number(map.day),
1355
            hour: Number(map.hour), minute: Number(map.minute), second: Number(map.second)
1356
        };
1357
    }
1358
1359
    function mostRecent3amCentral() {
1360
        const now = new Date();
1361
        const chicagoNow = getChicagoParts(now);
1362
1363
        function chicagoWallClockToUtc(year, month, day, hour) {
1364
            const guess = Date.UTC(year, month - 1, day, hour, 0, 0);
1365
            const guessChicagoParts = getChicagoParts(new Date(guess));
1366
            const guessAsUtc = Date.UTC(
1367
                guessChicagoParts.year, guessChicagoParts.month - 1, guessChicagoParts.day,
1368
                guessChicagoParts.hour, guessChicagoParts.minute, guessChicagoParts.second
1369
            );
1370
            return guess + (guess - guessAsUtc);
1371
        }
1372
1373
        const todayAt3amUtc = chicagoWallClockToUtc(chicagoNow.year, chicagoNow.month, chicagoNow.day, 3);
1374
1375
        if (todayAt3amUtc <= now.getTime()) return todayAt3amUtc;
1376
1377
        const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1378
        const chicagoYesterday = getChicagoParts(yesterday);
1379
        return chicagoWallClockToUtc(chicagoYesterday.year, chicagoYesterday.month, chicagoYesterday.day, 3);
1380
    }
1381
1382
    const staleBanner = document.getElementById('stale-banner');
1383
    const staleRefreshLink = document.getElementById('stale-refresh-link');
1384
1385
    function checkStaleness() {
1386
        if (pageLoadedAt < mostRecent3amCentral()) staleBanner.hidden = false;
1387
    }
1388
1389
    staleRefreshLink.addEventListener('click', (e) => {
1390
        e.preventDefault();
1391
        window.location.reload();
1392
    });
1393
1394
    document.addEventListener('visibilitychange', () => {
1395
        if (document.visibilityState === 'visible') checkStaleness();
1396
    });
1397
1398
    setInterval(checkStaleness, 5 * 60 * 1000); 
1399
1400
</script>
1401
</body>
1402
</html>
1403
HTML;
1404
1405
    return $indexTemplate;
1406
}
1407
1408
/**
1409
 * Write the generated index.php. Returns false if the write failed (e.g.
1410
 * the web server user can't write to the directory, or the file is locked
1411
 * on Windows) so callers can report it instead of falsely claiming success.
1412
 */
1413
function write_index_file(string $html, string $targetFile): bool
1414
{
1415
    $bytes = @file_put_contents($targetFile, $html);
1416
    if ($bytes === false) {
1417
        return false;
1418
    }
1419
    if (function_exists('opcache_invalidate')) {
1420
        opcache_invalidate($targetFile, true);
1421
    }
1422
    return true;
1423
}