594 lines · 22.2 KB
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
            '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
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 &rarr;</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>