1import { footballArticles, newsSource } from "./articles.js"; 2import { matchSource, results } from "./matches.js"; 3import { premierLeagueTable, standingsSource } from "./standings.js"; 4 5const MATCHES_ENDPOINT = "/.netlify/functions/matches"; 6const SPORTMONKS_REFRESH_INTERVAL_MS = 30 * 60_000; 7const SPORTMONKS_REFRESH_START_HOUR = 11; 8const SPORTMONKS_REFRESH_END_HOUR = 23; 9const NEWS_ENDPOINT = "/.netlify/functions/news"; 10const NEWS_REFRESH_INTERVAL_MS = 15 * 60_000; 11const STANDINGS_ENDPOINT = "/.netlify/functions/standings"; 12 13const PAGES = { 14 football: { 15 ariaLabel: "Ceefax football page", 16 pageNumber: "P302", 17 screenClass: "ceefax-screen--short", 18 title: "", 19 }, 20 fixtures: { 21 ariaLabel: "Ceefax Premiership fixtures", 22 pageNumber: "P315", 23 screenClass: "ceefax-screen--short", 24 title: "Premiership Fixtures", 25 }, 26 results: { 27 ariaLabel: "Ceefax Premiership results", 28 pageNumber: "P303", 29 screenClass: "ceefax-screen--short", 30 title: "Premiership Results", 31 }, 32 table: { 33 ariaLabel: "Ceefax Premier League table", 34 pageNumber: "P324", 35 screenClass: "ceefax-screen--table", 36 title: "Premier League", 37 }, 38}; 39 40const PAGE_NUMBER_ROUTES = { 41 302: "football", 42 303: "results", 43 315: "fixtures", 44 324: "table", 45}; 46 47const NAV_ITEMS = [ 48 { colour: "text-cee-red", id: "football", label: "News" }, 49 { colour: "text-cee-green", id: "fixtures", label: "Fixtures" }, 50 { colour: "text-cee-yellow", id: "results", label: "Results" }, 51 { colour: "text-cee-cyan", id: "table", label: "Table" }, 52]; 53 54const nowFormatter = new Intl.DateTimeFormat("en-GB", { 55 weekday: "short", 56 day: "2-digit", 57 month: "short", 58 timeZone: "Europe/London", 59}); 60 61const timeFormatter = new Intl.DateTimeFormat("en-GB", { 62 hour: "2-digit", 63 minute: "2-digit", 64 second: "2-digit", 65 hour12: false, 66 timeZone: "Europe/London", 67}); 68 69const sourceTimeFormatter = new Intl.DateTimeFormat("en-GB", { 70 hour: "2-digit", 71 minute: "2-digit", 72 hour12: false, 73 timeZone: "Europe/London", 74}); 75 76const londonHourFormatter = new Intl.DateTimeFormat("en-GB", { 77 hour: "2-digit", 78 hour12: false, 79 timeZone: "Europe/London", 80}); 81 82const app = document.querySelector("#app"); 83const storage = window.localStorage; 84let fixturesData = []; 85let newsArticles = footballArticles; 86let currentNewsSource = newsSource; 87let resultsData = results; 88let matchesSource = matchSource; 89let tableRows = premierLeagueTable; 90let tableSource = standingsSource; 91let isRefreshingMatches = false; 92let isRefreshingNews = false; 93let isRefreshingStandings = false; 94let isCrtEnabled = storage.getItem("footi-ceefax-crt") === "on"; 95let currentPageId = pageFromHash(); 96let remoteDigits = ""; 97let remoteTimeoutId; 98let renderRequestId = 0; 99 100function pageFromHash() { 101 const pageId = window.location.hash.replace("#", ""); 102 103 if (PAGES[pageId]) { 104 return pageId; 105 } 106 107 if (pageId.startsWith("article-")) { 108 const articlePage = Number(pageId.replace("article-", "")); 109 110 if (newsArticles.some((article) => article.page === articlePage)) { 111 return pageId; 112 } 113 } 114 115 const pageNumber = Number(pageId.replace(/^p/i, "")); 116 117 if (PAGE_NUMBER_ROUTES[pageNumber]) { 118 return PAGE_NUMBER_ROUTES[pageNumber]; 119 } 120 121 if (newsArticles.some((article) => article.page === pageNumber)) { 122 return `article-${pageNumber}`; 123 } 124 125 return "football"; 126} 127 128function pageIdForNumber(pageNumber) { 129 if (PAGE_NUMBER_ROUTES[pageNumber]) { 130 return PAGE_NUMBER_ROUTES[pageNumber]; 131 } 132 133 if (newsArticles.some((article) => article.page === pageNumber)) { 134 return `article-${pageNumber}`; 135 } 136 137 return ""; 138} 139 140function currentPage() { 141 if (currentPageId.startsWith("article-")) { 142 const page = Number(currentPageId.replace("article-", "")); 143 const article = newsArticles.find((item) => item.page === page); 144 145 return { 146 ariaLabel: article ? `Ceefax article: ${article.title}` : "Ceefax article", 147 pageNumber: `P${page}`, 148 screenClass: "ceefax-screen--article", 149 title: "", 150 }; 151 } 152 153 return PAGES[currentPageId]; 154} 155 156function currentArticle() { 157 const page = Number(currentPageId.replace("article-", "")); 158 159 return newsArticles.find((article) => article.page === page) ?? newsArticles[0]; 160} 161 162function escapeHtml(value) { 163 return String(value ?? "").replace(/[&<>"']/g, (character) => { 164 return ( 165 { 166 "&": "&", 167 "<": "<", 168 ">": ">", 169 '"': """, 170 "'": "'", 171 }[character] ?? character 172 ); 173 }); 174} 175 176function ceefaxText(value) { 177 return String(value ?? "") 178 .normalize("NFKD") 179 .replace(/[\u0300-\u036f]/g, "") 180 .replace(/Ã/g, "ss") 181 .replace(/æ/g, "ae") 182 .replace(/Ã/g, "AE") 183 .replace(/Å/g, "oe") 184 .replace(/Å/g, "OE") 185 .replace(/ø/g, "o") 186 .replace(/Ã/g, "O") 187 .replace(/Å/g, "l") 188 .replace(/Å/g, "L") 189 .replace(/ð/g, "d") 190 .replace(/Ã/g, "D") 191 .replace(/þ/g, "th") 192 .replace(/Ã/g, "Th"); 193} 194 195function escapeCeefaxText(value) { 196 return escapeHtml(ceefaxText(value)); 197} 198 199function zoneAfter(row) { 200 return row.pos === 5 || row.pos === 7 || row.pos === 8 || row.pos === 17; 201} 202 203function textColour(row) { 204 if (row.note === "Relegation" || row.pos >= 18) { 205 return "text-vee-red"; 206 } 207 208 return row.pos % 2 === 0 ? "text-cee-cyan" : "text-white"; 209} 210 211function shortTeamName(team) { 212 return ( 213 { 214 "AFC Bournemouth": "B'mouth", 215 "Aston Villa": "A Villa", 216 Bournemouth: "B'mouth", 217 Brentford: "Brentfd", 218 Brighton: "Brighton", 219 "Brighton & Hove Albion": "Brighton", 220 "Brighton and Hove Albion": "Brighton", 221 "C Palace": "Palace", 222 "Crystal Palace": "Palace", 223 "Leeds United": "Leeds", 224 "Man United": "Man Utd", 225 "Manchester City": "Man City", 226 "Manchester United": "Man Utd", 227 Newcastle: "Newcstle", 228 "Newcastle United": "Newcstle", 229 "Nottm Forest": "Forest", 230 "Nottingham Forest": "Forest", 231 Sunderland: "S'land", 232 "Tottenham Hotspur": "Spurs", 233 "West Ham": "W Ham", 234 "West Ham United": "W Ham", 235 "Wolverhampton Wanderers": "Wolves", 236 }[team] ?? team 237 ); 238} 239 240function teamLabel(team) { 241 const fullTeam = escapeCeefaxText(team); 242 const shortTeam = escapeCeefaxText(shortTeamName(team)); 243 244 return ` 245 <span class="team-full">${fullTeam}</span> 246 <span class="team-short">${shortTeam}</span> 247 `; 248} 249 250function matchTeamName(team) { 251 return ( 252 { 253 "Brighton and Hove Albion": "Brighton", 254 "Brighton & Hove Albion": "Brighton", 255 "Crystal Palace": "C Palace", 256 "Manchester City": "Man City", 257 "Manchester United": "Man United", 258 "Newcastle United": "Newcastle", 259 "Nottingham Forest": "Nottm Forest", 260 "Tottenham Hotspur": "Tottenham", 261 "West Ham United": "West Ham", 262 "Wolverhampton Wanderers": "Wolves", 263 }[team] ?? team 264 ); 265} 266 267function matchTeamLabel(team) {
268 return teamLabel(matchTeamName(team)); 269} 270 271function formatDate(date = new Date()) { 272 return nowFormatter.format(date).replace(",", ""); 273} 274 275function formatTime(date = new Date()) { 276 const parts = Object.fromEntries( 277 timeFormatter 278 .formatToParts(date) 279 .map(({ type, value }) => [type, value]), 280 ); 281 282 return `${parts.hour}:${parts.minute}/${parts.second}`; 283} 284 285function updateClock() { 286 const now = new Date(); 287 const date = document.querySelector("#current-date"); 288 const time = document.querySelector("#current-time"); 289 290 if (!date || !time) { 291 return; 292 } 293 294 date.textContent = formatDate(now); 295 time.textContent = formatTime(now); 296} 297 298function formatSourceLabel(source = tableSource) { 299 if (source.provider === "Sportmonks" && source.retrievedAt) { 300 const prefix = source.live ? "LIVE" : "UPD"; 301 302 return `${prefix} ${sourceTimeFormatter.format(new Date(source.retrievedAt))}`; 303 } 304 305 return source.retrievedLabel; 306} 307 308function sportmonksRefreshWindowOpen(date = new Date()) { 309 const hour = Number(londonHourFormatter.format(date)); 310 311 return hour >= SPORTMONKS_REFRESH_START_HOUR && hour < SPORTMONKS_REFRESH_END_HOUR; 312} 313 314function sourceDescription(source = tableSource) { 315 if (source.provider === "Sportmonks" && source.retrievedAt) { 316 return `Premier League standings from ${source.provider}, refreshed ${new Date( 317 source.retrievedAt, 318 ).toLocaleString("en-GB", { timeZone: "Europe/London" })}.`; 319 } 320 321 return `Premier League standings seeded from ${source.provider}, ${source.retrievedLabel}.`; 322} 323 324function renderNavigation() { 325 return NAV_ITEMS.map( 326 (item) => ` 327 <a 328 class="${item.colour}" 329 href="#${item.id}" 330 ${ 331 item.id === currentPageId || 332 (currentPageId.startsWith("article-") && item.id === "football") 333 ? 'aria-current="page"' 334 : "" 335 } 336 >${item.label}</a> 337 `, 338 ).join(""); 339} 340 341function renderRemote() { 342 const slots = [0, 1, 2] 343 .map((index) => `<span>${escapeHtml(remoteDigits[index] ?? "-")}</span>`) 344 .join(""); 345 const pageLabel = 346 remoteDigits.length === 3 347 ? pageIdForNumber(Number(remoteDigits)) 348 ? "TUNING" 349 : "NO SIGNAL" 350 : "TYPE PAGE"; 351 352 return ` 353 <div 354 id="ceefax-remote" 355 class="ceefax-remote ${remoteDigits ? "ceefax-remote--active" : ""}" 356 aria-live="polite" 357 aria-label="Ceefax remote page entry" 358 > 359 <div class="remote-led">${slots}</div> 360 <div class="remote-label">${pageLabel}</div> 361 <button 362 class="remote-crt-toggle" 363 type="button" 364 aria-pressed="${isCrtEnabled}" 365 aria-label="Toggle CRT filter" 366 title="Toggle CRT filter (C)" 367 > 368 CRT ${isCrtEnabled ? "ON" : "OFF"} 369 </button> 370 <div class="remote-pad" aria-hidden="true"> 371 ${["1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "OK"] 372 .map((key) => `<span>${key}</span>`) 373 .join("")} 374 </div> 375 </div> 376 `; 377} 378 379function renderTableRows(rows = tableRows) { 380 return rows 381 .map((row) => { 382 const colour = textColour(row); 383 const separator = zoneAfter(row) ? "separator" : ""; 384 385 return ` 386 <div class="ceefax-row ${colour} ${separator}" role="row"> 387 <span class="num" role="cell">${row.pos}</span> 388 <span class="team" role="cell">${teamLabel(row.team)}</span> 389 <span class="num" role="cell">${escapeHtml(row.played)}</span> 390 <span class="num" role="cell">${escapeHtml(row.won)}</span> 391 <span class="num stat-d" role="cell">${escapeHtml(row.drawn)}</span> 392 <span class="num stat-l" role="cell">${escapeHtml(row.lost)}</span> 393 <span class="num stat-fa" role="cell">${escapeHtml(row.goalsFor)}</span> 394 <span class="num stat-fa" role="cell">${escapeHtml(row.goalsAgainst)}</span> 395 <span class="num" role="cell">${escapeHtml(row.points)}</span> 396 </div> 397 `; 398 }) 399 .join(""); 400} 401 402function renderTableKey() { 403 return ` 404 <div class="table-key" aria-label="Premier League qualification key"> 405 <div><span class="text-white">1-5</span> UEFA Champions League</div> 406 <div><span class="text-white">6-7</span> UEFA Europa League</div> 407 <div><span class="text-white">8</span> UEFA Conference League Qualifiers</div> 408 <div><span class="text-white">18-20</span> Relegation</div> 409 </div> 410 `; 411} 412 413function renderTablePage() { 414 return ` 415 <div 416 class="ceefax-table mt-[2.6%]" 417 role="table" 418 aria-label="Premier League standings for ${standingsSource.season}" 419 > 420 <p id="standings-source-copy" class="sr-only">${sourceDescription()}</p> 421 <div class="ceefax-row ceefax-head text-white" role="row">
422 <span id="source-stamp" class="source-stamp" role="columnheader">${formatSourceLabel()}</span> 423 <span class="num" role="columnheader">P</span> 424 <span class="num" role="columnheader">W</span> 425 <span class="num stat-d" role="columnheader">D</span> 426 <span class="num stat-l" role="columnheader">L</span> 427 <span class="num stat-fa" role="columnheader">F</span> 428 <span class="num stat-fa" role="columnheader">A</span> 429 <span class="num" role="columnheader">Pts</span> 430 </div> 431 <div id="standings-rows" role="rowgroup">${renderTableRows()}</div> 432 ${renderTableKey()} 433 </div> 434 `; 435} 436 437function renderFixturesPage() { 438 if (fixturesData.length === 0) { 439 return ` 440 <div class="ceefax-match-list fixture-season-finished" role="status" aria-label="Season finished"> 441 <p>The 2025/26 season is finished.</p> 442 <p>Arsenal crowned champions.</p> 443 <p>Check back soon for next season's fixtures.</p> 444 </div> 445 `; 446 } 447 448 return ` 449 <div class="ceefax-match-list" role="list" aria-label="Premiership fixtures"> 450 ${fixturesData 451 .map( 452 (day) => ` 453 <section class="match-day" role="listitem" aria-label="${escapeHtml(day.date)}"> 454 <h2 class="match-day-title">${escapeHtml(day.date)}</h2> 455 ${day.matches 456 .map( 457 (match) => ` 458 <div class="fixture-row"> 459 <span class="match-time">${escapeHtml(match.kickOff)}</span> 460 <span class="match-team">${matchTeamLabel(match.home)}</span> 461 <span class="versus" aria-label="versus">v</span> 462 <span class="match-team">${matchTeamLabel(match.away)}</span> 463 </div> 464 `, 465 ) 466 .join("")} 467 </section> 468 `, 469 ) 470 .join("")} 471 </div> 472 `; 473} 474
475function renderScorers(scorers) { 476 if (!Array.isArray(scorers) || scorers.length === 0) { 477 return `<span aria-hidden="true"> </span>`; 478 } 479 480 return scorers.map((scorer) => `<span>${escapeCeefaxText(scorer)}</span>`).join(""); 481} 482 483function renderResultsPage() { 484 return ` 485 <div class="ceefax-match-list results-list" role="list" aria-label="Premiership results"> 486 ${resultsData 487 .map( 488 (match) => ` 489 <article class="result-match" role="listitem"> 490 ${ 491 match.date || match.kickOff 492 ? `<div class="match-meta">${escapeHtml([match.date, match.kickOff].filter(Boolean).join(" "))}</div>` 493 : "" 494 } 495 <div class="result-score-row"> 496 <span class="result-team match-team">${matchTeamLabel(match.home)}</span> 497 <span class="result-score">${escapeHtml(match.score)}</span> 498 <span class="result-team match-team">${matchTeamLabel(match.away)}</span> 499 <span class="result-status">${escapeHtml(match.status ?? "FT")}</span> 500 </div> 501 <div class="result-scorers"> 502 <span class="scorer-list">${renderScorers(match.homeScorers)}</span> 503 <span aria-hidden="true"></span> 504 <span class="scorer-list">${renderScorers(match.awayScorers)}</span> 505 <span aria-hidden="true"></span> 506 </div> 507 </article> 508 `, 509 ) 510 .join("")} 511 </div> 512 `; 513} 514 515function articleDate(article) { 516 const date = new Date(article.publishedAt); 517 518 if (Number.isNaN(date.getTime())) { 519 return ""; 520 } 521 522 return date.toUTCString().replace(" GMT", ""); 523} 524 525function renderArticlePage() { 526 const article = currentArticle(); 527 const paragraphs = [article.summary].filter(Boolean); 528 const published = articleDate(article); 529 const sourceUrl = article.url ? escapeHtml(article.url) : ""; 530 const sourceLabel = escapeHtml(`Read the original BBC Sport article: ${article.title}`); 531 532 return ` 533 <article class="ceefax-article"> 534 <h1>${escapeHtml(article.title)}</h1> 535 <div class="article-copy"> 536 ${paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("")} 537 </div> 538 ${ 539 sourceUrl 540 ? `<a class="article-link text-cee-cyan" href="${sourceUrl}" target="_blank" rel="noopener noreferrer" aria-label="${sourceLabel}"> 541 Read the original BBC Sport article. 542 </a>` 543 : "" 544 } 545 ${ 546 published 547 ? `<p class="article-date">${escapeHtml(published)}</p>` 548 : "" 549 } 550 </article> 551 `; 552} 553 554function renderFootballPage() { 555 return ` 556 <div class="ceefax-news-list" role="list" aria-label="BBC Sport football headlines"> 557 ${newsArticles 558 .map( 559 (article, index) => ` 560 <a 561 class="news-row ${index === 0 ? "news-row--lead" : ""}" 562 href="#${article.page}" 563 role="listitem" 564 > 565 <span>${escapeHtml(article.title)}</span> 566 <span class="news-page-num">${article.page}</span> 567 </a> 568 `, 569 ) 570 .join("")} 571 </div> 572 `; 573} 574 575function renderBody() { 576 if (currentPageId.startsWith("article-")) { 577 return renderArticlePage(); 578 } 579 580 return { 581 football: renderFootballPage, 582 fixtures: renderFixturesPage, 583 results: renderResultsPage, 584 table: renderTablePage, 585 }[currentPageId](); 586} 587 588function render() { 589 const page = currentPage(); 590 591 app.innerHTML = ` 592 <section 593 aria-label="${page.ariaLabel}" 594 class="mx-auto flex min-h-[calc(100dvh-2rem)] w-full max-w-[52rem] items-center justify-center" 595 > 596 <div 597 class="ceefax-screen ${page.screenClass} ${isCrtEnabled ? "ceefax-screen--crt" : ""} relative aspect-[3/4] w-full bg-black px-[6.7%] pb-[4.6%] pt-[4.2%] text-white shadow-[0_0_0_1px_rgba(255,255,255,0.09)]" 598 > 599 <header class="ceefax-header grid grid-cols-[1fr_auto] items-start gap-[1ch]"> 600 <div class="ceefax-brand leading-none tracking-normal">
601 <span>${page.pageNumber}</span> 602 <span class="ml-[0.55ch]">CEEFAX</span> 603 </div> 604 <div class="ceefax-clock flex gap-[1.2ch] whitespace-nowrap text-right leading-none"> 605 <span id="current-date">${formatDate()}</span> 606 <span id="current-time" class="text-cee-yellow">${formatTime()}</span> 607 </div> 608 </header> 609 610 <img 611 class="bbc-football-logo mt-[1.2%]" 612 src="./public/assets/bbc-football-logo.svg" 613 alt="BBC Football" 614 /> 615 616 <nav 617 aria-label="Ceefax navigation" 618 class="ceefax-nav flex items-center whitespace-nowrap leading-none" 619 > 620 ${renderNavigation()} 621 </nav> 622 623 ${ 624 page.title 625 ? `<div class="ceefax-title ceefax-page-title mt-[2.2%] text-cee-green uppercase leading-none">${page.title}</div>` 626 : "" 627 } 628 629 ${renderBody()} 630 631 ${renderRemote()} 632 </div> 633 </section> 634 `; 635} 636 637async function refreshNews({ renderAfter = true } = {}) { 638 if (isRefreshingNews || !(currentPageId === "football" || currentPageId.startsWith("article-"))) { 639 return; 640 } 641 642 isRefreshingNews = true; 643 644 try { 645 const response = await fetch(NEWS_ENDPOINT, { 646 cache: "no-store", 647 headers: { 648 Accept: "application/json", 649 }, 650 }); 651 652 if (response.status === 404 || response.status === 501) { 653 return; 654 } 655 656 if (!response.ok) { 657 throw new Error(`News request failed with ${response.status}`); 658 } 659 660 const payload = await response.json(); 661 662 if (Array.isArray(payload.articles) && payload.articles.length > 0) { 663 newsArticles = payload.articles; 664 currentNewsSource = { 665 ...newsSource, 666 ...payload.source, 667 }; 668 669 if ( 670 currentPageId.startsWith("article-") && 671 !newsArticles.some((article) => `article-${article.page}` === currentPageId) 672 ) { 673 currentPageId = "football"; 674 window.location.hash = "football"; 675 } 676 677 if (renderAfter) { 678 render(); 679 updateClock(); 680 } 681 } 682 } catch (error) { 683 console.info("Using seeded BBC football headlines until live news is available.", error, currentNewsSource); 684 } finally { 685 isRefreshingNews = false; 686 } 687} 688 689function updateStandingsView() { 690 const rows = document.querySelector("#standings-rows"); 691 const sourceStamp = document.querySelector("#source-stamp"); 692 const sourceCopy = document.querySelector("#standings-source-copy"); 693 694 if (!rows || !sourceStamp || !sourceCopy) { 695 return; 696 } 697 698 rows.innerHTML = renderTableRows(); 699 sourceStamp.textContent = formatSourceLabel(); 700 sourceStamp.classList.toggle("text-cee-yellow", tableSource.provider === "Sportmonks"); 701 sourceCopy.textContent = sourceDescription(); 702} 703 704async function refreshMatches({ force = false, renderAfter = true } = {}) { 705 if (isRefreshingMatches || !["fixtures", "results"].includes(currentPageId)) { 706 return; 707 } 708 709 if (!force && !sportmonksRefreshWindowOpen()) { 710 return; 711 } 712 713 isRefreshingMatches = true; 714 715 try { 716 const response = await fetch(MATCHES_ENDPOINT, { 717 cache: "no-store", 718 headers: { 719 Accept: "application/json", 720 }, 721 }); 722 723 if (response.status === 404 || response.status === 501) { 724 return; 725 } 726 727 if (!response.ok) { 728 throw new Error(`Matches request failed with ${response.status}`); 729 } 730 731 const payload = await response.json(); 732 733 if (Array.isArray(payload.fixtures)) { 734 fixturesData = payload.fixtures; 735 } 736 737 if (Array.isArray(payload.results) && payload.results.length > 0) { 738 resultsData = payload.results; 739 } 740 741 matchesSource = { 742 ...matchSource, 743 ...payload.source, 744 }; 745 746 if (renderAfter) { 747 render(); 748 updateClock(); 749 } 750 } catch (error) { 751 console.info("Using seeded fixtures/results until live data is available.", error, matchesSource); 752 } finally { 753 isRefreshingMatches = false; 754 } 755} 756 757async function refreshStandings({ force = false, renderAfter = true } = {}) { 758 if (isRefreshingStandings || currentPageId !== "table") { 759 return; 760 } 761 762 if (!force && !sportmonksRefreshWindowOpen()) { 763 return; 764 } 765 766 isRefreshingStandings = true; 767 768 try { 769 const response = await fetch(STANDINGS_ENDPOINT, { 770 cache: "no-store", 771 headers: { 772 Accept: "application/json", 773 }, 774 }); 775 776 if (response.status === 404 || response.status === 501) { 777 return; 778 } 779 780 if (!response.ok) { 781 throw new Error(`Standings request failed with ${response.status}`); 782 } 783 784 const payload = await response.json(); 785 const rows = Array.isArray(payload.standings) ? payload.standings : []; 786 787 if (rows.length === 0) { 788 return; 789 } 790 791 tableRows = rows.sort((a, b) => a.pos - b.pos).slice(0, 20); 792 tableSource = { 793 ...standingsSource, 794 ...payload.source, 795 provider: payload.source?.provider ?? "Sportmonks", 796 retrievedAt: payload.source?.retrievedAt ?? new Date().toISOString(), 797 }; 798 799 if (renderAfter) { 800 updateStandingsView(); 801 } 802 } catch (error) {
803 console.info("Using seeded standings until live data is available.", error); 804 } finally { 805 isRefreshingStandings = false; 806 } 807} 808 809async function preloadCurrentPageData() { 810 if (["fixtures", "results"].includes(currentPageId)) { 811 await refreshMatches({ renderAfter: false }); 812 return; 813 } 814 815 if (currentPageId === "table") { 816 await refreshStandings({ renderAfter: false }); 817 return; 818 } 819 820 if (currentPageId === "football" || currentPageId.startsWith("article-")) { 821 await refreshNews({ renderAfter: false }); 822 } 823} 824 825async function renderCurrentPage() { 826 const requestId = ++renderRequestId; 827 828 currentPageId = pageFromHash(); 829 await preloadCurrentPageData(); 830 831 if (requestId !== renderRequestId) { 832 return; 833 } 834 835 render(); 836 updateClock(); 837} 838 839function clearRemoteSoon(delay = 850) { 840 window.clearTimeout(remoteTimeoutId); 841 remoteTimeoutId = window.setTimeout(() => { 842 remoteDigits = ""; 843 render(); 844 updateClock(); 845 }, delay); 846} 847 848function handleRemoteDigit(digit) { 849 remoteDigits = `${remoteDigits}${digit}`.slice(-3); 850 render(); 851 updateClock(); 852 853 if (remoteDigits.length < 3) { 854 clearRemoteSoon(2500); 855 return; 856 } 857 858 const route = pageIdForNumber(Number(remoteDigits)); 859 860 if (route) { 861 window.location.hash = route; 862 clearRemoteSoon(); 863 return; 864 } 865 866 clearRemoteSoon(1100); 867} 868 869function toggleCrtFilter() { 870 isCrtEnabled = !isCrtEnabled; 871 storage.setItem("footi-ceefax-crt", isCrtEnabled ? "on" : "off"); 872 render(); 873 updateClock(); 874} 875 876function handleRemoteKeydown(event) { 877 if ( 878 event.altKey || 879 event.ctrlKey || 880 event.metaKey || 881 event.target instanceof HTMLInputElement || 882 event.target instanceof HTMLTextAreaElement || 883 event.target instanceof HTMLSelectElement 884 ) { 885 return; 886 } 887 888 if (/^\d$/.test(event.key)) { 889 event.preventDefault(); 890 handleRemoteDigit(event.key); 891 return; 892 } 893 894 if (event.key.toLowerCase() === "c") { 895 event.preventDefault(); 896 toggleCrtFilter(); 897 } 898} 899 900renderCurrentPage(); 901 902window.addEventListener("hashchange", renderCurrentPage); 903window.addEventListener("keydown", handleRemoteKeydown); 904window.addEventListener("click", (event) => { 905 if (event.target.closest(".remote-crt-toggle")) { 906 toggleCrtFilter(); 907 } 908}); 909window.setInterval(updateClock, 1_000); 910window.setInterval(refreshMatches, SPORTMONKS_REFRESH_INTERVAL_MS); 911window.setInterval(refreshNews, NEWS_REFRESH_INTERVAL_MS); 912window.setInterval(refreshStandings, SPORTMONKS_REFRESH_INTERVAL_MS);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.