In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

Football Matches

Football match results and predictions powered by Panna ratings

Football > Matches

Match-by-match predictions from the Panna model. H% = home win probability. D% = draw probability. A% = away win probability. Finished matches show xG (expected goals from shot quality) and xG-based win probability.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

base_url = window.DATA_BASE_URL

predictions = window.fetchParquet(base_url + "football/predictions.parquet")

// Load fixtures from R2 static JSON directly (worker returns incomplete data)
fixtureRaw = {
  try {
    const res = await fetch(window.DATA_BASE_URL + "football/fixtures.json")
    if (!res.ok) throw new Error("HTTP " + res.status)
    return await res.json()
  } catch (e) {
    console.warn("[matches] R2 fixture load failed, trying worker:", e)
    return await window.fetchFixtures("football")
  }
}
_fixtureData = fixtureRaw ? (fixtureRaw.matches || null) : null

// Build fixture lookup — empty initially, populates when fixtures load
fixtureMap = {
  const map = {}
  if (_fixtureData) {
    for (const f of _fixtureData) {
      const dateKey = f.date ? f.date.slice(0, 10) : ""
      map[`${f.league}|${dateKey}|${f.homeTeam}|${f.awayTeam}`] = f
    }
  }
  return map
}

lookupFixture = (league, matchDate, home, away) => {
  const dateKey = (matchDate || "").replace("Z", "").slice(0, 10)
  return fixtureMap[`${league}|${dateKey}|${home}|${away}`]
}

leagueNames = window.footballMaps.leagueNames

leagueOrder = ["ENG", "ESP", "GER", "ITA", "FRA", "UCL", "UEL", "UECL", "ENG2", "NED", "POR", "SCO", "TUR", "WC"]
Show code
leagueOptions = {
  if (!predictions) return []
  const available = [...new Set(predictions.map(d => d.league))]
  return leagueOrder
    .filter(code => available.includes(code))
    .map(code => ({ value: code, label: leagueNames[code] || code }))
}

viewof selectedLeague = predictions == null
  ? html`<p></p>`
  : Inputs.select(leagueOptions, {
      value: leagueOptions.find(d => d.value === "ENG") || leagueOptions[0],
      format: d => d.label,
      label: "League"
    })
Show code
viewof matchView = {
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = "Results"
  for (const label of ["Results", "Fixtures"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "Results" ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = label
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
formatKickoff = (isoDate) => {
  if (!isoDate) return ""
  const d = new Date(isoDate)
  if (isNaN(d)) return ""
  // Only show time if the ISO string has a time component (not just a date)
  if (isoDate.length <= 11) return ""
  return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false })
}

// Poisson win probability — delegates to shared football/win-prob.js
poissonWinProb = (xgHome, xgAway) => {
  if (window.footballXgProb) {
    const r = window.footballXgProb(xgHome, xgAway)
    return { pH: r.home, pD: r.draw, pA: r.away }
  }
  // Inline fallback if win-prob.js hasn't loaded
  console.warn("[matches] win-prob.js not loaded, using inline Poisson fallback")
  const maxK = 10, fact = [1]
  for (let i = 1; i <= maxK; i++) fact[i] = fact[i - 1] * i
  const pois = (lambda, k) => Math.exp(-lambda) * Math.pow(lambda, k) / fact[k]
  let pH = 0, pD = 0, pA = 0
  for (let i = 0; i <= maxK; i++) for (let j = 0; j <= maxK; j++) {
    const p = pois(xgHome, i) * pois(xgAway, j)
    if (i > j) pH += p; else if (i === j) pD += p; else pA += p
  }
  return { pH, pD, pA }
}

// Build prediction lookup for enriching fixture-sourced results.
// Keyed on normalized team names so fixture-form URLs ("Leeds United FC") resolve
// to predictions rows that may use a different form ("Leeds United"), which happens
// for pre-2026 historical matches where predictions stores the short form.
predMap = {
  if (!predictions) return {}
  const norm = window.normalizeTeam
  const map = {}
  for (const p of predictions) {
    const dateKey = (p.match_date || "").replace("Z", "").slice(0, 10)
    map[`${p.league}|${dateKey}|${norm(p.home_team)}|${norm(p.away_team)}`] = p
  }
  return map
}

lookupPred = (league, date, home, away) => {
  const norm = window.normalizeTeam
  const dateKey = (date || "").slice(0, 10)
  return predMap[`${league}|${dateKey}|${norm(home)}|${norm(away)}`] || null
}

leagueMatches = {
  if (!selectedLeague) return null
  const leagueCode = selectedLeague.value

  const today = new Date().toISOString().slice(0, 10)

  if (matchView === "Results") {
    // Source from fixtures (full season), enrich with predictions where available.
    // Accept matches with a score even if status isn't flipped to FINISHED yet
    // (football-data.org sometimes lags and leaves freshly-played games as IN_PLAY).
    if (!_fixtureData) return null
    const finished = _fixtureData.filter(f => {
      if (f.league !== leagueCode || f.homeScore == null) return false
      if (f.status === "FINISHED") return true
      // Fallback: if the match date is in the past and there's a score, treat as played
      const fDate = (f.date || "").slice(0, 10)
      return fDate && fDate < today
    })
    return finished
      .map(f => {
        const dateKey = (f.date || "").slice(0, 10)
        const pred = lookupPred(f.league, f.date, f.homeTeam, f.awayTeam)
        return {
          league: f.league,
          match_date: dateKey,
          home_team: f.homeTeam,
          away_team: f.awayTeam,
          match_id: pred?.match_id ?? null,  // carry through for Chain Viz link (optaId)
          prob_H: pred?.prob_H ?? null,
          prob_D: pred?.prob_D ?? null,
          prob_A: pred?.prob_A ?? null,
          pred_home_goals: pred?.pred_home_goals ?? null,
          pred_away_goals: pred?.pred_away_goals ?? null,
          xg_home: pred?.xg_home ?? null,
          xg_away: pred?.xg_away ?? null,
          // Attach fixture data directly so we don't need lookupFixture later
          _fixture: f
        }
      })
      .sort((a, b) => (b.match_date || "").localeCompare(a.match_date || ""))
  } else {
    // Fixtures: upcoming matches from predictions, soonest first. Filter on
    // match_date >= today rather than fixture status — fixtures.json is
    // current-season only, so historical predictions rows have no fixture row
    // to check against a status field.
    if (!predictions) return null
    const all = predictions.filter(d => {
      if (d.league !== leagueCode) return false
      const mDate = (d.match_date || "").replace("Z", "").slice(0, 10)
      return mDate && mDate >= today
    })
    return all
      .sort((a, b) => {
        const dateComp = (a.match_date || "").localeCompare(b.match_date || "")
        if (dateComp !== 0) return dateComp
        const fa = lookupFixture(a.league, a.match_date, a.home_team, a.away_team)
        const fb = lookupFixture(b.league, b.match_date, b.home_team, b.away_team)
        if (fa && fb && fa.date && fb.date) return fa.date.localeCompare(fb.date)
        return Math.max(b.prob_H, b.prob_A) - Math.max(a.prob_H, a.prob_A)
      })
  }
}

// Team form lookup — last 5 results per team from fixture data (with match links)
teamForm = {
  if (!_fixtureData) return {}
  const form = {}
  // Sort by date ascending so we can take the last 5
  const finished = _fixtureData
    .filter(f => f.status === "FINISHED" && f.homeScore != null)
    .sort((a, b) => (a.date || "").localeCompare(b.date || ""))
  for (const f of finished) {
    const league = f.league
    const hKey = `${league}|${f.homeTeam}`
    const aKey = `${league}|${f.awayTeam}`
    if (!form[hKey]) form[hKey] = []
    if (!form[aKey]) form[aKey] = []
    const url = `match.html#league=${encodeURIComponent(league)}&date=${f.date}&home=${encodeURIComponent(f.homeTeam)}&away=${encodeURIComponent(f.awayTeam)}`
    const hResult = f.homeScore > f.awayScore ? "W" : f.homeScore < f.awayScore ? "L" : "D"
    const aResult = f.homeScore > f.awayScore ? "L" : f.homeScore < f.awayScore ? "W" : "D"
    const scoreStr = ` (${f.homeScore}–${f.awayScore})`
    form[hKey].push({ result: hResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${hResult}` })
    form[aKey].push({ result: aResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${aResult}` })
  }
  // Keep last 5
  for (const k of Object.keys(form)) form[k] = form[k].slice(-5)
  return form
}

formDotsHtml = (league, team) => {
  const results = teamForm[`${league}|${team}`]
  if (!results || results.length === 0) return ""
  const esc = window.statsEsc
  return `<span class="form-dots">${results.map(r =>
    `<span class="form-dot form-${r.result.toLowerCase()}" data-tip="${esc(r.tip || "")}" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='${r.url}'"></span>`
  ).join("")}</span>`
}

matchCards = {
  if (!leagueMatches || leagueMatches.length === 0) {
    const msg = matchView === "Results"
      ? "No results available for this league yet."
      : "No upcoming fixtures for this league."
    return html`<p class="text-muted">${msg}</p>`
  }

  // Load team crests
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const crest = window.footballMaps.teamCrest
  const badgeHtml = (name) => {
    const url = crest(name)
    return url ? `<img src="${url}" alt="" class="team-badge" style="width:22px;height:22px;object-fit:contain;vertical-align:middle;margin-right:4px">` : ""
  }

  const showForm = true // show form dots on both Results and Fixtures (matches AFL pattern)

  // Group by date
  const byDate = new Map()
  for (const m of leagueMatches) {
    const d = (m.match_date || "").replace("Z", "")
    if (!byDate.has(d)) byDate.set(d, [])
    byDate.get(d).push(m)
  }

  const formatDate = (dateStr) => {
    const d = new Date(dateStr + "T12:00:00")
    return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" })
  }

  const sections = []
  for (const [date, matches] of byDate) {
    sections.push(`<div class="match-date-header">${formatDate(date)}</div>`)
    for (const m of matches) {
      const f = m._fixture || lookupFixture(m.league, m.match_date, m.home_team, m.away_team)
      const hasPred = m.prob_H != null

      const homeWin = hasPred && m.prob_H >= m.prob_A && m.prob_H >= m.prob_D
      const awayWin = hasPred && m.prob_A > m.prob_H && m.prob_A >= m.prob_D

      // Fixture enrichment
      const kickoff = f ? formatKickoff(f.date) : ""
      const venue = f?.venue || ""
      const infoParts = [kickoff, venue].filter(Boolean)
      const infoHtml = infoParts.length > 0
        ? `<div class="match-info">${statsEsc(infoParts.join(" · "))}</div>`
        : ""

      const finished = f && f.status === "FINISHED" && f.homeScore != null
      const isLive = f && (f.status === "IN_PLAY" || f.status === "PAUSED" || f.status === "LIVE")

      const hasXg = m.xg_home != null && m.xg_away != null
      const scoreHtml = finished
        ? `<div class="match-score">${f.homeScore} – ${f.awayScore}${hasXg ? `<div class="match-xg">xG: ${m.xg_home.toFixed(1)} – ${m.xg_away.toFixed(1)}</div>` : ""}</div>`
        : ""

      const matchDateKey = (m.match_date || "").replace("Z", "").slice(0, 10)
      const matchHref = `match.html#league=${encodeURIComponent(m.league)}&date=${matchDateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}`

      // Prediction + xG bars
      const dualBar = finished && hasXg && hasPred
      let predBarHtml = ""
      if (hasPred) {
        predBarHtml = `
          <div class="prob-bar-row">
            ${(dualBar || (finished && hasXg)) ? '<span class="prob-bar-label">Pred</span>' : ''}
            <div class="prob-bar-container">
              <div class="prob-bar prob-home" style="width: ${m.prob_H * 100}%">
                ${m.prob_H >= 0.15 ? (m.prob_H * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-draw" style="width: ${m.prob_D * 100}%">
                ${m.prob_D >= 0.15 ? (m.prob_D * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-away" style="width: ${m.prob_A * 100}%">
                ${m.prob_A >= 0.15 ? (m.prob_A * 100).toFixed(0) + '%' : ''}
              </div>
            </div>
          </div>`
      }

      let xgBarHtml = ""
      if (finished && hasXg) {
        const xgProb = poissonWinProb(m.xg_home, m.xg_away)
        xgBarHtml = `
          <div class="prob-bar-row">
            <span class="prob-bar-label">xG</span>
            <div class="prob-bar-container">
              <div class="prob-bar prob-home" style="width: ${xgProb.pH * 100}%">
                ${xgProb.pH >= 0.15 ? (xgProb.pH * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-draw" style="width: ${xgProb.pD * 100}%">
                ${xgProb.pD >= 0.15 ? (xgProb.pD * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-away" style="width: ${xgProb.pA * 100}%">
                ${xgProb.pA >= 0.15 ? (xgProb.pA * 100).toFixed(0) + '%' : ''}
              </div>
            </div>
          </div>`
      }

      const predSection = (predBarHtml || xgBarHtml) ? `
          <div class="match-prediction ${dualBar ? 'has-dual-bar' : ''}">
            <div class="prob-bars-group">
              ${predBarHtml}
              ${xgBarHtml}
            </div>
          </div>` : ""

      const predMargin = m.pred_home_goals != null && m.pred_away_goals != null ? m.pred_home_goals - m.pred_away_goals : null
      const predWinner = predMargin != null ? (predMargin > 0.05 ? m.home_team : predMargin < -0.05 ? m.away_team : null) : null

      const homeRating = m.pred_home_goals != null ? `<span class="rating">${m.pred_home_goals.toFixed(1)}</span>` : ""
      const awayRating = m.pred_away_goals != null ? `<span class="rating">${m.pred_away_goals.toFixed(1)}</span>` : ""

      // Build a stable match ID for live DOM updates
      const matchId = `${m.league}|${matchDateKey}|${m.home_team}|${m.away_team}`

      sections.push(`
        <a class="match-card-link football" href="${matchHref}">
        <div class="match-card football" data-match-id="${statsEsc(matchId)}">
          ${infoHtml}
          <div class="match-teams">
            <div class="team ${homeWin ? 'favoured' : ''}"><span class="team-link" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='team.html#team=${encodeURIComponent(m.home_team)}'">${badgeHtml(m.home_team)}${statsEsc(m.home_team)}</span>
              ${homeRating}
              ${showForm ? formDotsHtml(m.league, m.home_team) : ""}
            </div>
            <div class="match-vs">${finished ? scoreHtml : "vs"}</div>
            <div class="team ${awayWin ? 'favoured' : ''}"><span class="team-link" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='team.html#team=${encodeURIComponent(m.away_team)}'">${statsEsc(m.away_team)}${badgeHtml(m.away_team)}</span>
              ${awayRating}
              ${showForm ? formDotsHtml(m.league, m.away_team) : ""}
            </div>
          </div>
          ${predSection}
          <div class="match-card-footer">
            ${!finished && !isLive && m.pred_home_goals != null ? `<div class="pred-summary">Prediction: ${statsEsc(m.home_team)} ${m.pred_home_goals.toFixed(1)} : ${m.pred_away_goals.toFixed(1)} ${statsEsc(m.away_team)}</div>` : ""}
            <div class="match-chain-link"><span class="chain-nav" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='match-chains.html#league=${encodeURIComponent(m.league)}&date=${matchDateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}${m.match_id ? '&optaId=' + encodeURIComponent(m.match_id) : ''}'">Chains →</span></div>
          </div>
        </div>
        </a>`)
    }
  }

  return html`<div class="match-cards-container">${sections.join('')}</div>`
}
Show code
// Live score polling — updates match card scores and LIVE badges via DOM manipulation
_livePoller = {
  if (!window.footballLivePoller) return null

  const LIVE_STATUSES = { IN_PLAY: 1, PAUSED: 1, LIVE: 1, SUSPENDED: 1 }

  window.footballLivePoller.start({
    onScoreUpdate: (games) => {
      for (const g of games) {
        // Match by league|date|homeTeam|awayTeam
        const dateKey = (g.date || "").slice(0, 10)
        const matchId = `${g.league}|${dateKey}|${g.homeTeam}|${g.awayTeam}`
        const card = document.querySelector(`[data-match-id="${CSS.escape(matchId)}"]`)
        if (!card) { if (LIVE_STATUSES[g.status] === 1) console.warn("[livePoller] No card for live game:", matchId); continue }

        const isLive = LIVE_STATUSES[g.status] === 1
        const isFinished = g.status === "FINISHED"

        card.classList.toggle("is-live", isLive)

        // Update LIVE badge
        const info = card.querySelector(".match-info")
        if (info) {
          const existingBadge = info.querySelector(".live-badge")
          if (isLive && !existingBadge) {
            const badge = document.createElement("span")
            badge.className = "live-badge"
            badge.textContent = g.status === "PAUSED" ? "HT" : "LIVE"
            info.prepend(badge)
          } else if (isLive && existingBadge) {
            existingBadge.textContent = g.status === "PAUSED" ? "HT" : "LIVE"
          } else if (!isLive && existingBadge) {
            existingBadge.remove()
          }
        }

        // Update score in match-vs
        const vs = card.querySelector(".match-vs")
        if (vs && (isLive || isFinished) && g.homeScore != null) {
          while (vs.firstChild) vs.removeChild(vs.firstChild)
          const scoreDiv = document.createElement("div")
          scoreDiv.className = "match-score"
          scoreDiv.textContent = `${g.homeScore} – ${g.awayScore}`
          vs.appendChild(scoreDiv)
        }
      }
    }
  })

  invalidation.then(() => window.footballLivePoller.stop())
  return "polling"
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer