PageSourceSearch

https://footi-retro.netlify.app/src/main.js?v=20260706

js footi-retro.netlify.app collected 2026-10-03 10:44:40 UTC 25,314 bytes, 912 lines download raw bytes

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        "&": "&amp;",
167        "<": "&lt;",
168        ">": "&gt;",
169        '"': "&quot;",
170        "'": "&#39;",
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">&nbsp;</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.