| 1 |
<?php |
| 2 |
/** |
| 3 |
* generate.php |
| 4 |
*/ |
| 5 |
declare(strict_types=1); |
| 6 |
date_default_timezone_set('America/Chicago'); |
| 7 |
ini_set('memory_limit', '256M'); |
| 8 |
|
| 9 |
require_once __DIR__ . '/builder-core.php'; |
| 10 |
|
| 11 |
// The authorization key lives in secret.php (untracked) so it isn't |
| 12 |
// accidentally committed or shared. Copy secret.example.php to secret.php. |
| 13 |
require_once __DIR__ . '/secret.php'; |
| 14 |
|
| 15 |
if (defined('GENERATE_SECRET') && ($_GET['key'] ?? '') !== GENERATE_SECRET) { |
| 16 |
http_response_code(403); |
| 17 |
exit('Forbidden'); |
| 18 |
} |
| 19 |
|
| 20 |
define('BUILD_DIR', __DIR__ . '/tmp_builds'); |
| 21 |
|
| 22 |
// --- Live-update debug logging ------------------------------------------ |
| 23 |
// Writes a timestamped line to live-debug.log next to this script. Enabled |
| 24 |
// automatically for any level=live request, and for anything when ?debug=1 |
| 25 |
// is present. To turn it off, set LIVE_DEBUG to false below (or delete the |
| 26 |
// live-debug.log file to clear it — it's append-only). |
| 27 |
define('LIVE_DEBUG', false); |
| 28 |
define('LIVE_DEBUG_FILE', __DIR__ . '/live-debug.log'); |
| 29 |
|
| 30 |
function live_debug_enabled(): bool { |
| 31 |
if (!LIVE_DEBUG) return false; |
| 32 |
$level = $_GET['level'] ?? 'full'; |
| 33 |
return $level === 'live' || isset($_GET['debug']); |
| 34 |
} |
| 35 |
|
| 36 |
function live_log(string $msg): void { |
| 37 |
if (!live_debug_enabled()) return; |
| 38 |
$line = '[' . date('Y-m-d H:i:s') . '] ' |
| 39 |
. ($_GET['action'] ?? '?') . '/' . ($_GET['level'] ?? '?') |
| 40 |
. ' ' . $msg . "\n"; |
| 41 |
@file_put_contents(LIVE_DEBUG_FILE, $line, FILE_APPEND | LOCK_EX); |
| 42 |
} |
| 43 |
|
| 44 |
// A callable wrapper we can hand to fetch/apply helpers. |
| 45 |
function live_logger(): callable { |
| 46 |
return function (string $msg): void { live_log($msg); }; |
| 47 |
} |
| 48 |
|
| 49 |
function ensure_build_dir(): void { |
| 50 |
if (!is_dir(BUILD_DIR)) { |
| 51 |
mkdir(BUILD_DIR, 0700, true); |
| 52 |
} |
| 53 |
} |
| 54 |
|
| 55 |
function build_file_path(string $token): string { |
| 56 |
if (!preg_match('/^[a-f0-9]{32}$/', $token)) throw new InvalidArgumentException('Invalid token'); |
| 57 |
return BUILD_DIR . "/{$token}.json"; |
| 58 |
} |
| 59 |
|
| 60 |
function cleanup_stale_builds(): void { |
| 61 |
foreach ((glob(BUILD_DIR . '/*.json') ?: []) as $file) { |
| 62 |
if (filemtime($file) < time() - 2 * 3600) @unlink($file); |
| 63 |
} |
| 64 |
} |
| 65 |
|
| 66 |
function read_build(string $token): array { |
| 67 |
$path = build_file_path($token); |
| 68 |
if (!file_exists($path)) return []; |
| 69 |
|
| 70 |
$fh = fopen($path, 'r'); |
| 71 |
flock($fh, LOCK_SH); |
| 72 |
$raw = stream_get_contents($fh); |
| 73 |
flock($fh, LOCK_UN); |
| 74 |
fclose($fh); |
| 75 |
|
| 76 |
$data = json_decode($raw, true); |
| 77 |
return is_array($data) ? $data : []; |
| 78 |
} |
| 79 |
|
| 80 |
function merge_step_into_build(string $token, array $miniDb, bool $isFailure = false): void { |
| 81 |
$path = build_file_path($token); |
| 82 |
$fh = fopen($path, 'c+'); |
| 83 |
flock($fh, LOCK_EX); |
| 84 |
$raw = stream_get_contents($fh); |
| 85 |
$mainDb = json_decode($raw, true) ?: []; |
| 86 |
|
| 87 |
if (!isset($mainDb['_meta'])) $mainDb['_meta'] = ['failed_count' => 0]; |
| 88 |
if ($isFailure) $mainDb['_meta']['failed_count']++; |
| 89 |
|
| 90 |
$cityKeys = array_keys($miniDb); |
| 91 |
foreach ($cityKeys as $cKey) { |
| 92 |
if ($cKey === '_major_events' || $cKey === '_all_teams' || $cKey === '_meta') continue; |
| 93 |
if (!isset($miniDb[$cKey]['leagues'])) continue; |
| 94 |
|
| 95 |
foreach ($miniDb[$cKey]['leagues'] as $league => $lData) { |
| 96 |
if (empty($lData['games']) && empty($lData['upcoming'])) continue; |
| 97 |
|
| 98 |
if (!isset($mainDb[$cKey]['leagues'][$league])) { |
| 99 |
$mainDb[$cKey]['leagues'][$league] = ['latest_timestamp' => 0, 'live' => [], 'games' => [], 'upcoming' => []]; |
| 100 |
} |
| 101 |
|
| 102 |
$mainDb[$cKey]['leagues'][$league]['games'] = array_merge($mainDb[$cKey]['leagues'][$league]['games'], $lData['games']); |
| 103 |
$mainDb[$cKey]['leagues'][$league]['upcoming'] = array_merge($mainDb[$cKey]['leagues'][$league]['upcoming'], $lData['upcoming']); |
| 104 |
$mainDb[$cKey]['leagues'][$league]['latest_timestamp'] = max($mainDb[$cKey]['leagues'][$league]['latest_timestamp'], $lData['latest_timestamp']); |
| 105 |
} |
| 106 |
} |
| 107 |
|
| 108 |
if (!empty($miniDb['_major_events'])) { |
| 109 |
$mainDb['_major_events'] = array_merge($mainDb['_major_events'] ?? [], $miniDb['_major_events']); |
| 110 |
} |
| 111 |
|
| 112 |
ftruncate($fh, 0); |
| 113 |
rewind($fh); |
| 114 |
fwrite($fh, json_encode($mainDb)); |
| 115 |
fflush($fh); |
| 116 |
flock($fh, LOCK_UN); |
| 117 |
fclose($fh); |
| 118 |
} |
| 119 |
|
| 120 |
function merge_live_step_into_build(string $token, string $stepKey, array $data, array $CITIES): void { |
| 121 |
$path = build_file_path($token); |
| 122 |
$fh = fopen($path, 'c+'); |
| 123 |
flock($fh, LOCK_EX); |
| 124 |
$raw = stream_get_contents($fh); |
| 125 |
$mainDb = json_decode($raw, true) ?: []; |
| 126 |
|
| 127 |
if (empty($mainDb)) { |
| 128 |
live_log("merge stepKey=$stepKey: baseline build file was EMPTY before overlay (latest_db.json missing/blank?)"); |
| 129 |
} |
| 130 |
|
| 131 |
$liveBefore = live_count_all($mainDb); |
| 132 |
|
| 133 |
// Overlay live stats onto memory snapshot |
| 134 |
apply_live_scoreboards($mainDb, [$stepKey => $data], $CITIES, live_logger()); |
| 135 |
|
| 136 |
$liveAfter = live_count_all($mainDb); |
| 137 |
live_log("merge stepKey=$stepKey: total live records before=$liveBefore after=$liveAfter"); |
| 138 |
|
| 139 |
ftruncate($fh, 0); |
| 140 |
rewind($fh); |
| 141 |
fwrite($fh, json_encode($mainDb)); |
| 142 |
fflush($fh); |
| 143 |
flock($fh, LOCK_UN); |
| 144 |
fclose($fh); |
| 145 |
} |
| 146 |
|
| 147 |
/** Count every live record across all cities/leagues — used for before/after diagnostics. */ |
| 148 |
function live_count_all(array $db): int { |
| 149 |
$n = 0; |
| 150 |
foreach ($db as $cKey => $cData) { |
| 151 |
if (!is_array($cData) || empty($cData['leagues'])) continue; |
| 152 |
foreach ($cData['leagues'] as $lData) { |
| 153 |
if (!empty($lData['live'])) $n += count($lData['live']); |
| 154 |
} |
| 155 |
} |
| 156 |
return $n; |
| 157 |
} |
| 158 |
|
| 159 |
function json_response(array $payload, int $status = 200): void { |
| 160 |
http_response_code($status); |
| 161 |
header('Content-Type: application/json'); |
| 162 |
echo json_encode($payload); |
| 163 |
exit; |
| 164 |
} |
| 165 |
|
| 166 |
// --- AJAX actions -------------------------------------------------------- |
| 167 |
|
| 168 |
$action = $_GET['action'] ?? ''; |
| 169 |
$level = $_GET['level'] ?? 'full'; |
| 170 |
$steps = $level === 'live' ? build_live_step_list() : build_step_list($CITIES, $MAJOR_EVENTS, $SPORT_LABELS); |
| 171 |
|
| 172 |
if ($action !== '') { |
| 173 |
live_log(sprintf( |
| 174 |
'ROUTING action=%s level=%s (raw level param=%s) -> %s step list with %d steps', |
| 175 |
$action, |
| 176 |
$level, |
| 177 |
var_export($_GET['level'] ?? null, true), |
| 178 |
$level === 'live' ? 'LIVE' : 'FULL', |
| 179 |
count($steps) |
| 180 |
)); |
| 181 |
} |
| 182 |
|
| 183 |
if ($action === 'start') { |
| 184 |
ensure_build_dir(); |
| 185 |
cleanup_stale_builds(); |
| 186 |
|
| 187 |
$token = bin2hex(random_bytes(16)); |
| 188 |
|
| 189 |
if ($level === 'live') { |
| 190 |
$masterDbPath = __DIR__ . '/latest_db.json'; |
| 191 |
if (file_exists($masterDbPath)) { |
| 192 |
$db = json_decode(file_get_contents($masterDbPath), true) ?: []; |
| 193 |
file_put_contents(build_file_path($token), json_encode($db)); |
| 194 |
} else { |
| 195 |
json_response(['ok' => false, 'error' => 'No Full Build exists yet. Run Level 1 (Full) first to create the baseline.']); |
| 196 |
} |
| 197 |
} else { |
| 198 |
$emptyDb = aggregate_database([], $CITIES, $MAJOR_EVENTS, $SPORT_LABELS); |
| 199 |
file_put_contents(build_file_path($token), json_encode($emptyDb)); |
| 200 |
} |
| 201 |
|
| 202 |
json_response(['ok' => true, 'token' => $token, 'total' => count($steps)]); |
| 203 |
} |
| 204 |
|
| 205 |
if ($action === 'step') { |
| 206 |
$token = (string) ($_GET['token'] ?? ''); |
| 207 |
$index = (int) ($_GET['step'] ?? -1); |
| 208 |
|
| 209 |
try { build_file_path($token); } catch (InvalidArgumentException $e) { |
| 210 |
json_response(['ok' => false, 'error' => 'Invalid build token'], 400); |
| 211 |
} |
| 212 |
|
| 213 |
if ($index < 0 || $index >= count($steps)) { |
| 214 |
json_response(['ok' => false, 'error' => 'Invalid step index'], 400); |
| 215 |
} |
| 216 |
|
| 217 |
$step = $steps[$index]; |
| 218 |
$started = microtime(true); |
| 219 |
|
| 220 |
live_log("STEP #$index key={$step['key']} label=\"{$step['label']}\" starting fetch"); |
| 221 |
|
| 222 |
$data = fetch_step($step, live_logger()); |
| 223 |
$elapsed = (int) round((microtime(true) - $started) * 1000); |
| 224 |
|
| 225 |
live_log(sprintf( |
| 226 |
'STEP #%d key=%s fetch done in %dms — data=%s%s', |
| 227 |
$index, |
| 228 |
$step['key'], |
| 229 |
$elapsed, |
| 230 |
$data === null ? 'NULL (treated as failure)' : 'ok', |
| 231 |
(is_array($data) ? ' events=' . count($data['events'] ?? []) : '') |
| 232 |
)); |
| 233 |
|
| 234 |
if ($level === 'live') { |
| 235 |
if ($data !== null) { |
| 236 |
merge_live_step_into_build($token, $step['key'], $data, $CITIES); |
| 237 |
} else { |
| 238 |
live_log("STEP #$index key={$step['key']}: no data, recording as failed (no live overlay applied)"); |
| 239 |
merge_step_into_build($token, [], true); |
| 240 |
} |
| 241 |
} else { |
| 242 |
if ($data !== null) { |
| 243 |
$miniDb = aggregate_database([$step['key'] => $data], $CITIES, $MAJOR_EVENTS, $SPORT_LABELS); |
| 244 |
merge_step_into_build($token, $miniDb, false); |
| 245 |
} else { |
| 246 |
merge_step_into_build($token, [], true); |
| 247 |
} |
| 248 |
} |
| 249 |
|
| 250 |
json_response([ |
| 251 |
'ok' => true, |
| 252 |
'step' => $index, |
| 253 |
'label' => $step['label'], |
| 254 |
'success' => $data !== null, |
| 255 |
'elapsed_ms' => $elapsed, |
| 256 |
]); |
| 257 |
} |
| 258 |
|
| 259 |
if ($action === 'finalize') { |
| 260 |
$token = (string) ($_GET['token'] ?? ''); |
| 261 |
|
| 262 |
try { build_file_path($token); } catch (InvalidArgumentException $e) { |
| 263 |
json_response(['ok' => false, 'error' => 'Invalid build token'], 400); |
| 264 |
} |
| 265 |
|
| 266 |
$database = read_build($token); |
| 267 |
$failedCount = $database['_meta']['failed_count'] ?? 0; |
| 268 |
unset($database['_meta']); |
| 269 |
|
| 270 |
// Only full builds require sorting the completed/upcoming arrays. Live builds inherit |
| 271 |
// the pre-sorted structure directly from latest_db.json |
| 272 |
if ($level === 'full') { |
| 273 |
$cityKeys = array_keys($CITIES); |
| 274 |
$cityKeys[] = 'all'; |
| 275 |
foreach ($cityKeys as $cKey) { |
| 276 |
if (!isset($database[$cKey]['leagues'])) continue; |
| 277 |
uasort($database[$cKey]['leagues'], function ($a, $b) { |
| 278 |
return $b['latest_timestamp'] <=> $a['latest_timestamp']; |
| 279 |
}); |
| 280 |
foreach ($database[$cKey]['leagues'] as &$lData) { |
| 281 |
usort($lData['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); |
| 282 |
usort($lData['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; }); |
| 283 |
} |
| 284 |
unset($lData); |
| 285 |
} |
| 286 |
usort($database['_major_events'], function ($a, $b) { |
| 287 |
return strtotime($b['date_raw'] ?? 'now') <=> strtotime($a['date_raw'] ?? 'now'); |
| 288 |
}); |
| 289 |
|
| 290 |
// Save the compiled, sorted, fresh baseline schedule for future live runs |
| 291 |
file_put_contents(__DIR__ . '/latest_db.json', json_encode($database)); |
| 292 |
} |
| 293 |
|
| 294 |
$timestamp = date('l, F j, Y g:i:s A T'); |
| 295 |
$html = render_index_html($database, $CITIES, $timestamp); |
| 296 |
|
| 297 |
$targetFile = __DIR__ . '/index.php'; |
| 298 |
$wrote = write_index_file($html, $targetFile); |
| 299 |
@unlink(build_file_path($token)); |
| 300 |
|
| 301 |
if (!$wrote) { |
| 302 |
json_response([ |
| 303 |
'ok' => false, |
| 304 |
'error' => "Build finished but writing {$targetFile} failed. Check that the web server can write to that path (file permissions, or the file is locked).", |
| 305 |
]); |
| 306 |
} |
| 307 |
|
| 308 |
json_response([ |
| 309 |
'ok' => true, |
| 310 |
'timestamp' => $timestamp, |
| 311 |
'team_count' => count($database['_all_teams'] ?? []), |
| 312 |
'city_count' => count($CITIES), |
| 313 |
'failed_count' => $failedCount, |
| 314 |
]); |
| 315 |
} |
| 316 |
|
| 317 |
// --- CRON actions -------------------------------------------------------- |
| 318 |
|
| 319 |
if ($action === 'cron_run') { |
| 320 |
ensure_build_dir(); |
| 321 |
cleanup_stale_builds(); |
| 322 |
$token = bin2hex(random_bytes(16)); |
| 323 |
|
| 324 |
if ($level === 'live') { |
| 325 |
$masterDbPath = __DIR__ . '/latest_db.json'; |
| 326 |
if (file_exists($masterDbPath)) { |
| 327 |
$db = json_decode(file_get_contents($masterDbPath), true) ?: []; |
| 328 |
file_put_contents(build_file_path($token), json_encode($db)); |
| 329 |
} else { |
| 330 |
exit("Error: No Full Build exists. Run Level 1 before running Level 2 cron.\n"); |
| 331 |
} |
| 332 |
} else { |
| 333 |
$emptyDb = aggregate_database([], $CITIES, $MAJOR_EVENTS, $SPORT_LABELS); |
| 334 |
file_put_contents(build_file_path($token), json_encode($emptyDb)); |
| 335 |
} |
| 336 |
|
| 337 |
$failedCount = 0; |
| 338 |
|
| 339 |
foreach ($steps as $step) { |
| 340 |
live_log("CRON STEP key={$step['key']} label=\"{$step['label']}\""); |
| 341 |
$data = fetch_step($step, live_logger()); |
| 342 |
live_log(sprintf('CRON STEP key=%s data=%s%s', $step['key'], |
| 343 |
$data === null ? 'NULL' : 'ok', |
| 344 |
is_array($data) ? ' events=' . count($data['events'] ?? []) : '')); |
| 345 |
if ($data !== null) { |
| 346 |
if ($level === 'live') { |
| 347 |
merge_live_step_into_build($token, $step['key'], $data, $CITIES); |
| 348 |
} else { |
| 349 |
$miniDb = aggregate_database([$step['key'] => $data], $CITIES, $MAJOR_EVENTS, $SPORT_LABELS); |
| 350 |
merge_step_into_build($token, $miniDb, false); |
| 351 |
unset($miniDb); |
| 352 |
} |
| 353 |
} else { |
| 354 |
merge_step_into_build($token, [], true); |
| 355 |
$failedCount++; |
| 356 |
} |
| 357 |
unset($data); |
| 358 |
} |
| 359 |
|
| 360 |
$database = read_build($token); |
| 361 |
unset($database['_meta']); |
| 362 |
|
| 363 |
if ($level === 'full') { |
| 364 |
$cityKeys = array_keys($CITIES); |
| 365 |
$cityKeys[] = 'all'; |
| 366 |
foreach ($cityKeys as $cKey) { |
| 367 |
if (!isset($database[$cKey]['leagues'])) continue; |
| 368 |
uasort($database[$cKey]['leagues'], function ($a, $b) { |
| 369 |
return $b['latest_timestamp'] <=> $a['latest_timestamp']; |
| 370 |
}); |
| 371 |
foreach ($database[$cKey]['leagues'] as &$lData) { |
| 372 |
usort($lData['games'], function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); |
| 373 |
usort($lData['upcoming'], function ($a, $b) { return $a['timestamp'] <=> $b['timestamp']; }); |
| 374 |
} |
| 375 |
unset($lData); |
| 376 |
} |
| 377 |
usort($database['_major_events'], function ($a, $b) { |
| 378 |
return strtotime($b['date_raw'] ?? 'now') <=> strtotime($a['date_raw'] ?? 'now'); |
| 379 |
}); |
| 380 |
|
| 381 |
file_put_contents(__DIR__ . '/latest_db.json', json_encode($database)); |
| 382 |
} |
| 383 |
|
| 384 |
$timestamp = date('l, F j, Y g:i:s A T'); |
| 385 |
$html = render_index_html($database, $CITIES, $timestamp); |
| 386 |
|
| 387 |
$wrote = write_index_file($html, __DIR__ . '/index.php'); |
| 388 |
@unlink(build_file_path($token)); |
| 389 |
|
| 390 |
echo "Cron build ($level) complete at {$timestamp}.\n"; |
| 391 |
echo "Failed requests: {$failedCount}\n"; |
| 392 |
if (!$wrote) { |
| 393 |
echo "WARNING: could not write index.php — check that the web server can write to " . __DIR__ . ".\n"; |
| 394 |
} |
| 395 |
exit; |
| 396 |
} |
| 397 |
|
| 398 |
// --- Default: render the build page -------------------------------------- |
| 399 |
|
| 400 |
$stepsFullJs = json_encode(array_map(fn($s) => ['label' => $s['label']], build_step_list($CITIES, $MAJOR_EVENTS, $SPORT_LABELS)), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); |
| 401 |
$stepsLiveJs = json_encode(array_map(fn($s) => ['label' => $s['label']], build_live_step_list()), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); |
| 402 |
$keyParam = isset($_GET['key']) ? '&key=' . urlencode((string) $_GET['key']) : ''; |
| 403 |
?> |
| 404 |
<!DOCTYPE html> |
| 405 |
<html lang="en"> |
| 406 |
<head> |
| 407 |
<meta charset="UTF-8"> |
| 408 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 409 |
<title>Build Casual Fan Data</title> |
| 410 |
<style> |
| 411 |
:root { |
| 412 |
color-scheme: dark light; |
| 413 |
--bg-color: #f8fafc; |
| 414 |
--card-bg: #ffffff; |
| 415 |
--text-primary: #0f172a; |
| 416 |
--text-secondary: #475569; |
| 417 |
--border: #e2e8f0; |
| 418 |
--radius: 8px; |
| 419 |
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); |
| 420 |
--accent: #2563eb; |
| 421 |
--live: #e11d48; |
| 422 |
--ok: #16a34a; |
| 423 |
--fail: #dc2626; |
| 424 |
} |
| 425 |
@media (prefers-color-scheme: dark) { |
| 426 |
:root { |
| 427 |
--bg-color: #0f172a; |
| 428 |
--card-bg: #1e293b; |
| 429 |
--text-primary: #f8fafc; |
| 430 |
--text-secondary: #94a3b8; |
| 431 |
--border: #334155; |
| 432 |
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.5); |
| 433 |
--live: #f43f5e; |
| 434 |
} |
| 435 |
} |
| 436 |
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg-color); color: var(--text-primary); margin: 0; padding: 1rem; } |
| 437 |
.container { max-width: 700px; margin: 0 auto; } |
| 438 |
h1 { font-size: 1.25rem; font-weight: 800; margin: 0 0 0.25rem 0; } |
| 439 |
p.sub { color: var(--text-secondary); margin: 0 0 1rem 0; font-size: 0.9rem; } |
| 440 |
|
| 441 |
.panel { |
| 442 |
background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); |
| 443 |
box-shadow: var(--shadow); padding: 1rem; margin-bottom: 1rem; |
| 444 |
} |
| 445 |
|
| 446 |
.controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; } |
| 447 |
button { |
| 448 |
font: inherit; font-weight: 700; font-size: 0.85rem; padding: 0.55rem 1rem; |
| 449 |
border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); |
| 450 |
color: white; cursor: pointer; |
| 451 |
} |
| 452 |
button.live-btn { border-color: var(--live); background: var(--live); } |
| 453 |
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; } |
| 454 |
|
| 455 |
.progress-track { height: 10px; border-radius: 999px; background: var(--border); overflow: hidden; flex: 1; min-width: 150px; } |
| 456 |
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width 0.15s ease-out; } |
| 457 |
.progress-fill.live { background: var(--live); } |
| 458 |
.progress-count { font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap; } |
| 459 |
|
| 460 |
.log { |
| 461 |
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| 462 |
font-size: 0.78rem; line-height: 1.5; max-height: 360px; overflow-y: auto; |
| 463 |
background: var(--bg-color); border: 1px solid var(--border); border-radius: 6px; |
| 464 |
padding: 0.6rem 0.75rem; |
| 465 |
} |
| 466 |
.log-line { display: flex; gap: 0.5rem; white-space: pre-wrap; word-break: break-word; } |
| 467 |
.log-line .icon { flex: none; width: 1.1em; } |
| 468 |
.log-line.ok .icon { color: var(--ok); } |
| 469 |
.log-line.fail .icon { color: var(--fail); } |
| 470 |
.log-line .ms { flex: none; color: var(--text-secondary); } |
| 471 |
|
| 472 |
.summary { font-size: 0.9rem; } |
| 473 |
.summary strong { color: var(--ok); } |
| 474 |
.summary a { color: var(--accent); } |
| 475 |
</style> |
| 476 |
</head> |
| 477 |
<body> |
| 478 |
<main class="container"> |
| 479 |
<h1>Build Casual Fan Data</h1> |
| 480 |
<p class="sub">Run Level 1 to compile all schedules, or Level 2 to fetch quick live updates.</p> |
| 481 |
|
| 482 |
<div class="panel"> |
| 483 |
<div class="controls"> |
| 484 |
<button id="start-btn-full" onclick="runBuild('full')">Full Build (Level 1)</button> |
| 485 |
<button id="start-btn-live" class="live-btn" onclick="runBuild('live')">Live Update (Level 2)</button> |
| 486 |
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div> |
| 487 |
<div class="progress-count" id="progress-count">Ready</div> |
| 488 |
</div> |
| 489 |
<div class="log" id="log"></div> |
| 490 |
</div> |
| 491 |
|
| 492 |
<div class="panel" id="summary-panel" hidden> |
| 493 |
<div class="summary" id="summary"></div> |
| 494 |
</div> |
| 495 |
</main> |
| 496 |
|
| 497 |
<script> |
| 498 |
const STEPS_FULL = <?php echo $stepsFullJs; ?>; |
| 499 |
const STEPS_LIVE = <?php echo $stepsLiveJs; ?>; |
| 500 |
const KEY_PARAM = <?php echo json_encode($keyParam); ?>; |
| 501 |
|
| 502 |
const btnFull = document.getElementById('start-btn-full'); |
| 503 |
const btnLive = document.getElementById('start-btn-live'); |
| 504 |
const progressFill = document.getElementById('progress-fill'); |
| 505 |
const progressCount = document.getElementById('progress-count'); |
| 506 |
const log = document.getElementById('log'); |
| 507 |
const summaryPanel = document.getElementById('summary-panel'); |
| 508 |
const summary = document.getElementById('summary'); |
| 509 |
|
| 510 |
function escapeHTML(str) { |
| 511 |
return String(str).replace(/[&<>'"]/g, tag => ({ |
| 512 |
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"' |
| 513 |
}[tag] || tag)); |
| 514 |
} |
| 515 |
|
| 516 |
function appendLog(ok, label, ms) { |
| 517 |
const row = document.createElement('div'); |
| 518 |
row.className = 'log-line ' + (ok ? 'ok' : 'fail'); |
| 519 |
row.innerHTML = `<span class="icon">${ok ? '✓' : '✗'}</span><span class="label">${escapeHTML(label)}</span><span class="ms">${ms}ms</span>`; |
| 520 |
log.appendChild(row); |
| 521 |
log.scrollTop = log.scrollHeight; |
| 522 |
} |
| 523 |
|
| 524 |
function setProgress(done, total) { |
| 525 |
progressFill.style.width = (done / total * 100).toFixed(1) + '%'; |
| 526 |
progressCount.textContent = `${done} / ${total}`; |
| 527 |
} |
| 528 |
|
| 529 |
async function runBuild(level) { |
| 530 |
btnFull.disabled = true; |
| 531 |
btnLive.disabled = true; |
| 532 |
|
| 533 |
const isLive = level === 'live'; |
| 534 |
const steps = isLive ? STEPS_LIVE : STEPS_FULL; |
| 535 |
const total = steps.length; |
| 536 |
|
| 537 |
if (isLive) progressFill.classList.add('live'); |
| 538 |
else progressFill.classList.remove('live'); |
| 539 |
|
| 540 |
log.innerHTML = ''; |
| 541 |
summaryPanel.hidden = true; |
| 542 |
setProgress(0, total); |
| 543 |
|
| 544 |
const startRes = await fetch(`generate.php?action=start&level=${level}${KEY_PARAM}`); |
| 545 |
const startData = await startRes.json(); |
| 546 |
|
| 547 |
if (!startData.ok) { |
| 548 |
appendLog(false, 'Could not start build: ' + (startData.error || 'unknown error'), 0); |
| 549 |
btnFull.disabled = false; |
| 550 |
btnLive.disabled = false; |
| 551 |
return; |
| 552 |
} |
| 553 |
|
| 554 |
const token = startData.token; |
| 555 |
let failedCount = 0; |
| 556 |
|
| 557 |
for (let i = 0; i < total; i++) { |
| 558 |
try { |
| 559 |
const res = await fetch(`generate.php?action=step&token=${token}&step=${i}&level=${level}${KEY_PARAM}`); |
| 560 |
const data = await res.json(); |
| 561 |
if (!data.ok) { |
| 562 |
appendLog(false, steps[i].label + ' — request failed', 0); |
| 563 |
failedCount++; |
| 564 |
} else { |
| 565 |
appendLog(data.success, data.label, data.elapsed_ms); |
| 566 |
if (!data.success) failedCount++; |
| 567 |
} |
| 568 |
} catch (e) { |
| 569 |
appendLog(false, steps[i].label + ' — network error', 0); |
| 570 |
failedCount++; |
| 571 |
} |
| 572 |
setProgress(i + 1, total); |
| 573 |
} |
| 574 |
|
| 575 |
appendLog(true, 'Rendering index.php…', 0); |
| 576 |
const finalizeRes = await fetch(`generate.php?action=finalize&token=${token}&level=${level}${KEY_PARAM}`); |
| 577 |
const finalizeData = await finalizeRes.json(); |
| 578 |
|
| 579 |
btnFull.disabled = false; |
| 580 |
btnLive.disabled = false; |
| 581 |
|
| 582 |
summaryPanel.hidden = false; |
| 583 |
if (finalizeData.ok) { |
| 584 |
summary.innerHTML = `<strong>Done.</strong> Generated index.php at ${escapeHTML(finalizeData.timestamp)} |
| 585 |
covering ${finalizeData.team_count} teams across ${finalizeData.city_count} cities |
| 586 |
(${finalizeData.failed_count} request${finalizeData.failed_count === 1 ? '' : 's'} failed). |
| 587 |
<br><a href="index.php" target="_blank">View index.php →</a>`; |
| 588 |
} else { |
| 589 |
summary.innerHTML = `<span style="color: var(--fail)">Finalize failed: ${escapeHTML(finalizeData.error || 'unknown error')}</span>`; |
| 590 |
} |
| 591 |
} |
| 592 |
</script> |
| 593 |
</body> |
| 594 |
</html> |