import {computeScoreDisplay, mergeLiveScores, knockoutRounds} from "./live-scores.js"
teams = transpose(teams_data).sort((a, b) => a.name.localeCompare(b.name))
// Build-time snapshot from R. The optional "Update scores" button overlays
// fresh scores onto this via `liveEntries` (see below); when the button isn't
// used (or isn't configured) `matches === baseMatches`.
baseMatches = transpose(matches_data)
.map(d => ({...d, utc_date: new Date(d.utc_date_str)}))
.sort((a, b) => a.utc_date - b.utc_date)
now = new Date()
// Public proxy URL injected at render time (empty when the feature is off).
WORKER_URL = (typeof score_proxy_url === "string" ? score_proxy_url : "").trim()
// Live overlay, set by the button. Empty by default, so `matches` matches the
// build-time snapshot until the visitor asks for an update.
mutable liveEntries = []
matches = mergeLiveScores(baseMatches, liveEntries, now)
.slice()
.sort((a, b) => a.utc_date - b.utc_date)FIFA 2026 World Cup
Schedule and results
This page rebuilds about every 10 minutes during match windows, with live scores from football-data.org’s paid API tier. An in-progress match shows a running scoreline with the match clock (e.g. “1–0 (live 67’)”). Because the page is a periodic snapshot, the scoreline and clock can trail the live match by a few minutes. There is an Update Scores button below that uses JavaScript to fetch up-to-date scores if a match is live you want to check.
Last rebuilt: July 4, 2026 at 19:27 EDT
localTz = Intl.DateTimeFormat().resolvedOptions().timeZone
tzOptions = new Map([
[`Your local time (${localTz})`, localTz],
["UTC", "UTC"],
["US Eastern", "America/New_York"],
["US Central", "America/Chicago"],
["US Mountain", "America/Denver"],
["US Pacific", "America/Los_Angeles"],
["Mexico City", "America/Mexico_City"],
["São Paulo", "America/Sao_Paulo"],
["London", "Europe/London"],
["Central Europe (Paris/Berlin/Madrid)", "Europe/Paris"],
["Athens / Cairo", "Europe/Athens"],
["Moscow", "Europe/Moscow"],
["Dubai", "Asia/Dubai"],
["India", "Asia/Kolkata"],
["Tokyo / Seoul", "Asia/Tokyo"],
["Beijing / Singapore", "Asia/Shanghai"],
["Sydney", "Australia/Sydney"],
["Auckland", "Pacific/Auckland"]
])
viewof selectedTz = Inputs.select(tzOptions, {label: "Time zone", value: localTz})
tz = selectedTzPick your time zone above — both tabs update. (Defaults to your device’s time zone.)
// "Update scores" button — only rendered when a proxy URL was configured at
// render time (WORLDCUP26_SCORE_PROXY_URL). It fetches current scores from the
// Cloudflare Worker (which keeps the API key secret) and overlays them onto the
// page via `liveEntries`, no site rebuild needed. Returns null (renders
// nothing) when the feature is off.
updateBar = {
if (!WORKER_URL) return null;
const status = html`<span class="update-status"></span>`;
const btn = html`<button class="update-btn" type="button">Update scores</button>`;
btn.onclick = async () => {
btn.disabled = true;
status.classList.remove("error");
status.textContent = "Updating…";
try {
const resp = await fetch(WORKER_URL, {headers: {Accept: "application/json"}});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
mutable liveEntries = Array.isArray(data.matches) ? data.matches : [];
status.textContent = `Updated ${new Date().toLocaleTimeString()}`;
} catch (err) {
status.classList.add("error");
status.textContent = "Couldn't reach live scores — try again.";
} finally {
btn.disabled = false;
}
};
return html`<div class="update-bar">${btn}${status}</div>`;
}selectedTeam = teams.find(t => t.name === selectedTeamName)
teamMatches = matches.filter(
m => m.home_team_id === selectedTeam.id || m.away_team_id === selectedTeam.id
)
nextMatch = teamMatches.find(
m => (m.utc_date >= now || liveStatuses.has(m.status)) &&
!finishedStatuses.has(m.status) &&
!inactiveStatuses.has(m.status)
)
pastMatches = teamMatches.filter(
m => m.utc_date < now &&
!liveStatuses.has(m.status) &&
!inactiveStatuses.has(m.status)
)
upcoming = teamMatches.filter(
m => (m.utc_date >= now || liveStatuses.has(m.status)) &&
!finishedStatuses.has(m.status) &&
!inactiveStatuses.has(m.status)
)Next match
Upcoming matches
Past results
ko = knockoutRounds(matches)
ko.rounds.every(r => r.matches.length === 0)
? html`<p class="muted">The knockout bracket fills in here once the group
stage finishes — check back after the final group matches.</p>`
: html`<p class="ko-note muted">Dates and kick-off times are official, but
next-round matchups can lag the results, so an upcoming game may read “TBD”
here even after the feeding match is decided. For the latest confirmed
fixtures, see the
<a href="https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/scores-fixtures"
target="_blank" rel="noopener">official FIFA fixtures page</a>.</p>
${bracketColumns(ko)}`Third-place race
liveStatuses = new Set(["IN_PLAY","PAUSED","EXTRA_TIME","PENALTY_SHOOTOUT"])
finishedStatuses = new Set(["FINISHED","AWARDED"])
inactiveStatuses = new Set(["CANCELLED","POSTPONED","SUSPENDED"])
teamById = new Map(teams.map(t => [t.id, t]))
function crestFor(name, id) {
const t = teamById.get(id);
return t
? html`<img src="${t.crest_url}" alt="" class="crest-sm"> ${name}`
: html`<span class="tbd">${name ?? "TBD"}</span>`;
}
function groupLabelFor(team) {
const m = matches.find(
x => (x.home_team_id === team.id || x.away_team_id === team.id) &&
x.stage === "GROUP_STAGE"
);
return m && m.group ? `Group ${m.group.replace("GROUP_", "")}` : "—";
}
function stageLabel(stage, group) {
if (!stage) return "";
if (stage === "GROUP_STAGE") return group ? `Group ${group.replace("GROUP_", "")}` : "Group stage";
return stage.replace(/_/g, " ").toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
// All times follow the selected `tz` (see the time-zone picker). Building a
// fresh formatter per call keeps things reactive when `tz` changes.
function dayKey(d) {
// e.g. "2026-06-12" — the calendar date in the selected zone.
return new Intl.DateTimeFormat("en-CA", {timeZone: tz}).format(d);
}
function formatDate(d) {
return d.toLocaleString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", timeZone: tz, timeZoneName: "short"
});
}
function formatDateShort(d) {
return d.toLocaleDateString("en-US", {weekday: "short", month: "short", day: "numeric", timeZone: tz});
}
function formatTime(d) {
return d.toLocaleTimeString("en-US", {hour: "numeric", minute: "2-digit", timeZone: tz, timeZoneName: "short"});
}
function formatDayHeading(isoDay) {
// isoDay is already the selected-zone calendar date; format it as UTC midday
// so the weekday/month/day read back out without a second timezone shift.
const d = new Date(isoDay + "T12:00:00Z");
return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC"});
}
function statusBadge(m) {
if (liveStatuses.has(m.status)) return html`<span class="badge live">LIVE</span>`;
if (m.status === "POSTPONED") return html`<span class="badge postponed">Postponed</span>`;
if (m.status === "CANCELLED") return html`<span class="badge cancelled">Cancelled</span>`;
if (m.status === "SUSPENDED") return html`<span class="badge suspended">Suspended</span>`;
return "";
}
function nextMatchCard(m, team) {
if (!m) {
return html`<div class="next-card empty">No upcoming matches on the schedule.</div>`;
}
const isHome = m.home_team_id === team.id;
const opp_id = isHome ? m.away_team_id : m.home_team_id;
const opp_t = teamById.get(opp_id);
const oppName = isHome ? m.away_team : m.home_team;
return html`
<div class="next-card">
<div class="next-date">${formatDate(m.utc_date)}</div>
<div class="next-matchup">
<div class="opponent">
${opp_t ? html`<img src="${opp_t.crest_url}" class="crest-md" alt="">` : ""}
<div>
<div class="vs-label">${isHome ? "vs" : "at"}</div>
<div class="opponent-name">${oppName ?? "TBD"}</div>
</div>
</div>
<div class="next-stage">${stageLabel(m.stage, m.group)}${statusBadge(m)}</div>
</div>
${m.venue ? html`<div class="next-venue">${m.venue}</div>` : ""}
</div>`;
}
// `score_display` from R is always home–away. On a team page we want the
// selected team's goals first, so the same match reads as a win for one
// side and a loss for the other. Non-numeric strings ("in progress",
// "in progress (80')", "no score available yet", "postponed", "") pass
// through unchanged.
function flipScore(s) {
if (!s) return s;
// Final with penalties: "1–1 (4–3 PK)" — swap both pairs.
let m = s.match(/^(\d+)–(\d+)\s*\((\d+)–(\d+)\s*PK\)$/);
if (m) return `${m[2]}–${m[1]} (${m[4]}–${m[3]} PK)`;
// Live line, optionally with a clock: "1–0 (live)" / "1–0 (live 45+2')" —
// swap the goals, keep the suffix verbatim (it has no per-team numbers).
m = s.match(/^(\d+)–(\d+)(\s*\(live[^)]*\))$/);
if (m) return `${m[2]}–${m[1]}${m[3]}`;
// Plain full-time line: "2–1".
m = s.match(/^(\d+)–(\d+)$/);
if (m) return `${m[2]}–${m[1]}`;
return s;
}
function scheduleTable(rows, team) {
return html`<table class="schedule">
<thead>
<tr>
<th>Date</th><th>Kick-off</th><th>Opponent</th><th>H/A</th>
<th>Stage</th><th>Score</th>
</tr>
</thead>
<tbody>
${rows.map(m => {
const isHome = m.home_team_id === team.id;
const opp_id = isHome ? m.away_team_id : m.home_team_id;
const oppName = isHome ? m.away_team : m.home_team;
const score = isHome ? m.score_display : flipScore(m.score_display);
return html`<tr>
<td>${formatDateShort(m.utc_date)}</td>
<td>${formatTime(m.utc_date)}</td>
<td>${crestFor(oppName, opp_id)}</td>
<td>${isHome ? "H" : "A"}</td>
<td>${stageLabel(m.stage, m.group)}</td>
<td class="score">${score || ""} ${statusBadge(m)}</td>
</tr>`;
})}
</tbody>
</table>`;
}
function matchListTable(rows) {
return html`<table class="day-table">
<tbody>
${rows.map(m => html`<tr>
<td class="time">${formatTime(m.utc_date)}</td>
<td class="team home">${crestFor(m.home_team, m.home_team_id)}</td>
<td class="vs">vs</td>
<td class="team away">${crestFor(m.away_team, m.away_team_id)}</td>
<td class="stage">${stageLabel(m.stage, m.group)}</td>
<td class="score">${m.score_display || ""} ${statusBadge(m)}</td>
</tr>`)}
</tbody>
</table>`;
}
// ---- Knockout bracket -------------------------------------------------------
// Which side has advanced, for a finished knockout match: "home", "away", or
// null (not finished, or level with no shoot-out data). Penalties break a draw.
function koWinnerSide(m) {
if (!finishedStatuses.has(m.status)) return null;
if (m.home_score != null && m.away_score != null && m.home_score !== m.away_score) {
return m.home_score > m.away_score ? "home" : "away";
}
if (m.home_pk != null && m.away_pk != null && m.home_pk !== m.away_pk) {
return m.home_pk > m.away_pk ? "home" : "away";
}
return null;
}
// One knockout match: date/time (in the selected tz), both teams (TBD when a
// slot isn't decided yet), the scoreline, and any status badge. The advancing
// side is bolded once the match is final.
function koCard(m) {
const winner = koWinnerSide(m);
const sideClass = side => winner === side ? "ko-team winner" : "ko-team";
return html`<div class="ko-card">
<div class="ko-date">${formatDateShort(m.utc_date)} · ${formatTime(m.utc_date)}</div>
<div class="${sideClass("home")}">${crestFor(m.home_team, m.home_team_id)}</div>
<div class="${sideClass("away")}">${crestFor(m.away_team, m.away_team_id)}</div>
<div class="ko-score">${m.score_display || ""} ${statusBadge(m)}</div>
</div>`;
}
// One bracket column: a round heading and its match cards (a placeholder when
// the round has no fixtures yet).
function bracketRound(round, headingTag = "h3") {
const heading = headingTag === "h4"
? html`<h4 class="round-subheading">${round.label}</h4>`
: html`<h3 class="round-heading">${round.label}</h3>`;
return html`<div class="bracket-round">
${heading}
${round.matches.length > 0
? round.matches.map(koCard)
: html`<div class="ko-card empty"><span class="tbd">To be determined</span></div>`}
</div>`;
}
// The full bracket: the five main rounds as left→right columns, with the
// third-place playoff tucked beneath the Final.
function bracketColumns(ko) {
return html`<div class="bracket-grid">
${ko.rounds.map(r => {
if (r.stage === "FINAL" && ko.thirdPlace) {
return html`<div class="bracket-round final-col">
<h3 class="round-heading">${r.label}</h3>
${r.matches.length > 0
? r.matches.map(koCard)
: html`<div class="ko-card empty"><span class="tbd">To be determined</span></div>`}
${bracketRound(ko.thirdPlace, "h4")}
</div>`;
}
return bracketRound(r);
})}
</div>`;
}
// Goal difference with an explicit + for positive values.
function fmtGd(gd) {
return gd > 0 ? `+${gd}` : `${gd}`;
}
// One group's standings table. Rows arrive already ordered by rank. The top two
// (who advance directly) are highlighted; teams level on every available
// tiebreaker carry a "tie" flag (the deciding criteria — cards, FIFA ranking —
// aren't in the data).
function standingsTable(letter, rows) {
const complete = rows.length > 0 && rows[0].group_complete;
return html`<section class="standings-group">
<h3 class="standings-heading">Group ${letter}
<span class="standings-state">${complete ? "Final" : "In progress"}</span>
</h3>
<table class="schedule standings">
<thead>
<tr><th>#</th><th>Team</th><th>P</th><th>W</th><th>D</th><th>L</th>
<th>GF</th><th>GA</th><th>GD</th><th>Pts</th></tr>
</thead>
<tbody>
${rows.map(r => html`<tr class="${r.rank <= 2 ? "qualified" : ""}">
<td>${r.rank}</td>
<td>${crestFor(r.team, r.team_id)}${r.tie_unresolved
? html` <span class="tie-flag" title="Level on every available tiebreaker">tie</span>`
: ""}</td>
<td>${r.played}</td><td>${r.won}</td><td>${r.drawn}</td><td>${r.lost}</td>
<td>${r.gf}</td><td>${r.ga}</td><td>${fmtGd(r.gd)}</td>
<td class="pts">${r.points}</td>
</tr>`)}
</tbody>
</table>
</section>`;
}
// The cross-group race for the eight best third-placed teams. A line after
// position 8 marks the qualification cut; while groups are in progress the
// ordering is provisional.
function thirdPlaceTable(rows) {
const provisional = rows.length > 0 && rows[0].provisional;
return html`<div class="third-place">
<table class="schedule standings">
<thead>
<tr><th>Pos</th><th>Group</th><th>Team</th><th>P</th><th>Pts</th>
<th>GD</th><th>GF</th></tr>
</thead>
<tbody>
${rows.map(r => html`<tr class="${r.currently_advancing ? "qualified" : "out"}${r.position === 8 ? " cut-line" : ""}">
<td>${r.position}</td>
<td>${r.group_letter}</td>
<td>${crestFor(r.team, r.team_id)}</td>
<td>${r.played}</td>
<td class="pts">${r.points}</td>
<td>${fmtGd(r.gd)}</td>
<td>${r.gf}</td>
</tr>`)}
</tbody>
</table>
<p class="muted">${provisional
? "Top eight are currently advancing — provisional while groups are in progress; both the third-placed team and its record can still change."
: "The top eight advance to the Round of 32."}</p>
</div>`;
}Data: football-data.org · Code: worldcup26 R package