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