In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

Football Matches

Skip to content

Football > Matches

Football · Match Predictions · Panna Model

Who wins this matchday?

Matchday picks across 15 leagues, driven by Panna squad strengths. Live games update in-play; finished matches surface the underlying xG story of who actually deserved the result.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>Live during games · daily otherwise</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="leagues.html">Leagues &nearr;</a></span>
  <span>&approx; 3 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

base_url = window.DATA_BASE_URL

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

// Per-match-id xG totals (home / away) straight from predictions.parquet's
// xg_home/xg_away. This replaced a 15.7 MB match-shots.parquet client-side
// aggregation (2026-06-12): pannadata now writes real xg_home/xg_away for
// every club league (ENG/ESP/GER/ITA/FRA/NED/POR/SCO/TUR/ENG2 at 100%,
// UCL/UEL near-100%, UECL partial), and the giant parquet sat on the card
// render's critical path — re-downloaded every 5 min by fetchParquet's
// cache-bust. Matches without populated xG simply show no xG line.
xgTotalsByMatch = {
  if (!predictions) return new Map()
  const totals = new Map()  // match_id -> { home: num, away: num }
  const gapByLeague = {}
  for (const p of predictions) {
    if (p.match_id && p.xg_home != null && p.xg_away != null) {
      totals.set(p.match_id, { home: p.xg_home, away: p.xg_away })
    } else if (p.match_id && p.status === "played") {
      gapByLeague[p.league] = (gapByLeague[p.league] || 0) + 1
    }
  }
  // Coverage diagnostic: the removed match-shots fallback used to paper over
  // xg_home gaps invisibly — if a league's coverage regresses upstream, this
  // is the only signal short of a reader noticing missing xG lines.
  const gaps = Object.entries(gapByLeague).filter(([, n]) => n > 0)
  if (gaps.length) console.info("[matches] finished matches without parquet xG:", Object.fromEntries(gaps))
  return totals
}

// 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 = "Live"
  for (const label of ["Live", "All", "Fixtures", "Results"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "Live" ? " active" : "")
    btn.dataset.view = label
    btn.textContent = label
    btn.addEventListener("click", () => {
      // Claim ownership of the toggle so the auto-default cell below can't
      // clobber a user click made before the first poll lands. The auto cell
      // sets the flag only at the bottom of its run, so a click during the
      // pre-poll window (up to FETCH_TIMEOUT_MS) would otherwise lose to the
      // auto-switch when _pollerSnapshot finally populates.
      window._matchViewAutoSwitched = true
      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
}

// Once fixtures resolve, if there are no live games ANYWHERE and the user
// hasn't clicked a tab yet, auto-switch to Fixtures so the page doesn't land
// on an empty "Live" tab. Tracks a window flag so we only run once per page
// load — re-running on every reactive tick would fight a user who clicked Live.
_autoMatchViewDefault = {
  const view = viewof matchView
  if (!view || window._matchViewAutoSwitched) return null
  if (_fixtureData == null) return null  // fixtures still loading
  // Wait for the first poll cycle before deciding — _fixtureData can be ~15 min
  // stale (worker cache), so a match that's gone live since last refresh would
  // be missed and we'd auto-switch to Fixtures right before it shows. Once the
  // poller's first callback has fired, _pollerSnapshot is non-null and we have
  // authoritative live status to consult.
  if (_pollerSnapshot == null) return null
  const LIVE_STATUSES = new Set(["IN_PLAY", "PAUSED", "LIVE", "SUSPENDED"])
  const pollerById = _pollerSnapshot.byId || new Map()
  const anyLive = _fixtureData.some(f =>
    LIVE_STATUSES.has(pollerById.get(f.id)?.status ?? f.status)
  )
  if (!anyLive) {
    // Update the viewof value + dispatch input directly rather than
    // programmatic .click() (per feedback_ojs_click_testing: synthetic
    // clicks don't always propagate through OJS reactivity reliably).
    // Also flip the active class explicitly so the visual state matches.
    view.querySelectorAll('.epv-toggle-btn').forEach(b => b.classList.toggle('active', b.dataset.view === 'Fixtures'))
    view.value = "Fixtures"
    view.dispatchEvent(new Event("input", { bubbles: true }))
  }
  window._matchViewAutoSwitched = true
  return anyLive ? "Live" : "Fixtures"
}
Show code
// Live-poller snapshot: keyed mirror of the poller's latest games list, used
// by the Live tab to OVERLAY fresh status / score onto the season-fixed
// _fixtureData. Without this, a match that flips IN_PLAY → FINISHED while
// the page is open stays on the Live tab forever (and a match that goes
// IN_PLAY after page load never appears).
// Lives in its own {ojs} block — `mutable` declarations need to be the only
// statement in their cell for Quarto's OJS parser to recognize them.
// Initial value is `null` (simple literal); the poller callback installs a
// `{ byId, _sig, updated }` object on first poll. Readers must null-guard.
mutable _pollerSnapshot = null
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
}

// Statuses football-data.org reports for in-play matches (mirrors the worker
// and live-poller). Used by the Live tab + by the auto-default behaviour above.
LIVE_FIXTURE_STATUSES = new Set(["IN_PLAY", "PAUSED", "LIVE", "SUSPENDED"])

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

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

  if (matchView === "Live") {
    // Live: in-play matches for this league, enriched with predictions where
    // available (same enrich shape as Results so the card renderer doesn't
    // need a third code path). Overlay the live-poller's snapshot on top of
    // _fixtureData so status / score reflect the latest poll (~60s old at
    // worst) rather than the page-load snapshot — without this overlay, a
    // match that goes FINISHED while the user is reading the page sticks on
    // the Live tab until they reload.
    if (!_fixtureData) return null
    const pollerById = (_pollerSnapshot && _pollerSnapshot.byId) || new Map()
    const effectiveStatus = (f) => pollerById.get(f.id)?.status ?? f.status
    const live = _fixtureData.filter(f =>
      f.league === leagueCode && LIVE_FIXTURE_STATUSES.has(effectiveStatus(f))
    )
    return live
      .map(f => {
        const dateKey = (f.date || "").slice(0, 10)
        const pred = lookupPred(f.league, f.date, f.homeTeam, f.awayTeam)
        const fresh = pollerById.get(f.id)
        // Overlay fresh status + score onto the fixture row so the card
        // renderer (which reads _fixture.status / _fixture.homeScore for
        // isLive detection and score display) doesn't have to know about
        // the poller snapshot.
        const effFixture = fresh
          ? { ...f, status: fresh.status, homeScore: fresh.homeScore ?? f.homeScore, awayScore: fresh.awayScore ?? f.awayScore }
          : f
        return {
          league: f.league,
          match_date: dateKey,
          // Prefer the Opta name from predictions ("Manchester United") over the
          // football-data.org TEAM_MAP'd name ("Manchester United FC") for display.
          home_team: pred?.home_team ?? f.homeTeam,
          away_team: pred?.away_team ?? f.awayTeam,
          match_id: pred?.match_id ?? null,
          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,
          _fixture: effFixture
        }
      })
      .sort((a, b) => (a.match_date || "").localeCompare(b.match_date || ""))
  } else 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,
          // Prefer Opta names from predictions over fixtures' TEAM_MAP'd "...FC" forms.
          home_team: pred?.home_team ?? f.homeTeam,
          away_team: pred?.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 if (matchView === "All") {
    // All: every match for this league from predictions (past + upcoming),
    // most-recent first. The card renderer resolves scores via lookupFixture,
    // so raw prediction rows are enough — same shape as the Fixtures branch.
    if (!predictions) return null
    return predictions
      .filter(d => d.league === leagueCode)
      .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-${esc(String(r.result || "").toLowerCase())}" data-tip="${esc(r.tip || "")}" aria-label="${esc(r.tip || r.result || "match")}" data-nav-url="${esc(r.url || "")}" role="link" tabindex="0"></span>`
  ).join("")}</span>`
}

matchCards = {
  if (!leagueMatches || leagueMatches.length === 0) {
    // Gate the "no live matches" line on _pollerSnapshot — pre-first-poll
    // we genuinely don't know yet (~15s worst case after the timeout bump);
    // claiming "no live matches" then is misleading.
    const livePending = matchView === "Live" && _pollerSnapshot == null
    const msg = livePending
      ? "Checking for live matches…"
      : matchView === "Live"
      ? "No live matches in this league right now. Switch to Fixtures for upcoming games or Results for finished ones."
      : matchView === "Results"
      ? "No results available for this league yet."
      : matchView === "All"
      ? "No matches 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

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

      // Fixture enrichment. Pre-render the LIVE badge synchronously when we
      // already know the match is in-play, so the user sees LIVE on first
      // paint instead of waiting for the first poll tick. The .match-info
      // container is the live poller's hook for later badge / status updates;
      // it's lazily created by the poller for cards rendered without one
      // (e.g. when fixture data is missing on first paint).
      const kickoff = f ? formatKickoff(f.date) : ""
      const venue = f?.venue || ""
      const infoParts = [kickoff, venue].filter(Boolean)
      const liveBadgeHtml = isLive
        ? `<span class="live-badge">${f.status === "PAUSED" ? "HT" : "LIVE"}</span>`
        : ""
      // Render .match-info whenever we have ANY content (live badge or kickoff/venue).
      // The live poller targets this container to add/update the badge later if the
      // game flips to in-play after first paint.
      const infoHtml = (liveBadgeHtml || infoParts.length > 0)
        ? `<div class="match-info">${liveBadgeHtml}${statsEsc(infoParts.join(" · "))}</div>`
        : ""

      // Resolve xG: the row's own xg_* first, else the by-id lookup (same
      // parquet — covers rows reached via a different join path).
      const _shotTotals = m.match_id ? xgTotalsByMatch.get(m.match_id) : null
      const xgH = m.xg_home != null ? m.xg_home : (_shotTotals ? _shotTotals.home : null)
      const xgA = m.xg_away != null ? m.xg_away : (_shotTotals ? _shotTotals.away : null)
      const hasXg = xgH != null && xgA != null
      // Live cards already know the current score from _fixtureData (the worker
      // refreshes it every 15 min from football-data.org). Render it in the
      // initial paint so the user sees "2 – 1" instead of "vs" before the
      // first live-poller tick lands ~15-60s later.
      const showScore = finished || (isLive && f && f.homeScore != null)
      const scoreHtml = showScore
        ? `<div class="match-score">${f.homeScore} – ${f.awayScore}${hasXg ? `<div class="match-xg">xG: ${xgH.toFixed(1)} – ${xgA.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
      // Cheaply detect that we'll also be rendering a Live bar below the Pred
      // bar, so we can label them ("Pred" / "Live") instead of leaving the
      // first one unlabeled. xG-bar case stays labelled via dualBar.
      const willHaveLiveBar = isLive && f && f.homeScore != null && window.footballLiveWp
      let predBarHtml = ""
      if (hasPred) {
        predBarHtml = `
          <div class="prob-bar-row">
            ${(dualBar || (finished && hasXg) || willHaveLiveBar) ? '<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.05 ? (m.prob_H * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-draw" style="width: ${m.prob_D * 100}%">
                ${m.prob_D >= 0.05 ? (m.prob_D * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-away" style="width: ${m.prob_A * 100}%">
                ${m.prob_A >= 0.05 ? (m.prob_A * 100).toFixed(0) + '%' : ''}
              </div>
            </div>
          </div>`
      }

      let xgBarHtml = ""
      if (finished && hasXg) {
        const xgProb = poissonWinProb(xgH, xgA)
        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.05 ? (xgProb.pH * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-draw" style="width: ${xgProb.pD * 100}%">
                ${xgProb.pD >= 0.05 ? (xgProb.pD * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-away" style="width: ${xgProb.pA * 100}%">
                ${xgProb.pA >= 0.05 ? (xgProb.pA * 100).toFixed(0) + '%' : ''}
              </div>
            </div>
          </div>`
      }

      // Live WP bar (AFL parity). Estimate effective minute from kickoff time:
      //   1H regular  → elapsed
      //   HT (status PAUSED) → snap to 47 (~45 + median 1H stoppage)
      //   2H regular  → elapsed minus 15-min HT break
      //   capped at the league's nominal end so the WP model doesn't extrapolate
      // Imperfect (no per-match stoppage available here), but accurate to within
      // a couple of minutes which is well inside the WP model's resolution.
      let liveBarHtml = ""
      if (isLive && f && f.homeScore != null && window.footballLiveWp) {
        // Per-card try/catch: an unknown league code or model regression
        // throwing from footballLiveWp would otherwise nuke the entire
        // matchCards cell — every card in the tab disappears, not just this
        // broken one. Swallow + warn, leave liveBarHtml empty for this card.
        try {
          const kickoffMs = f.date ? new Date(f.date).getTime() : null
          let minute = 1
          if (kickoffMs) {
            const elapsed = Math.max(0, (Date.now() - kickoffMs) / 60000)
            if (f.status === "PAUSED") minute = 47
            else if (elapsed < 47) minute = elapsed
            else minute = elapsed - 15
            minute = Math.max(1, Math.min(95, minute))
          }
          const probs = window.footballLiveWp(minute, f.homeScore, f.awayScore, m.league)
          const showDraw = probs.draw >= 0.05
          liveBarHtml = `
            <div class="prob-bar-row" data-wp-bar>
              <span class="prob-bar-label">Live</span>
              <div class="prob-bar-container">
                <div class="prob-bar prob-home" style="width: ${probs.home * 100}%">
                  ${probs.home >= 0.05 ? (probs.home * 100).toFixed(0) + '%' : ''}
                </div>
                ${showDraw ? `<div class="prob-bar prob-draw" style="width: ${probs.draw * 100}%">
                  ${probs.draw >= 0.05 ? (probs.draw * 100).toFixed(0) + '%' : ''}
                </div>` : ''}
                <div class="prob-bar prob-away" style="width: ${(showDraw ? probs.away : 1 - probs.home) * 100}%">
                  ${probs.away >= 0.05 ? (probs.away * 100).toFixed(0) + '%' : ''}
                </div>
              </div>
            </div>`
        } catch (e) {
          console.warn("[matches] live WP failed for " + m.league + " " + m.home + "/" + m.away + ":", e.message)
        }
      }

      const predSection = (predBarHtml || xgBarHtml || liveBarHtml) ? `
          <div class="match-prediction ${(dualBar || liveBarHtml) ? 'has-dual-bar' : ''}">
            <div class="prob-bars-group">
              ${predBarHtml}
              ${xgBarHtml}
              ${liveBarHtml}
            </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. Normalize team names
      // (strip FC/AFC/etc, lowercase) so a prediction-side name like
      // "Manchester United" still matches a live-poller name like
      // "Manchester United FC" — otherwise the live poller's querySelector
      // returns null and the LIVE badge / score update is silently dropped.
      const _norm = window.normalizeTeam || (s => s)
      const matchId = `${m.league}|${matchDateKey}|${_norm(m.home_team)}|${_norm(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" data-nav-url="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">${showScore ? scoreHtml : "vs"}</div>
            <div class="team ${awayWin ? 'favoured' : ''}"><span class="team-link" role="link" tabindex="0" data-nav-url="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">
            ${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" aria-label="Chain visualizer: ${statsEsc(m.home_team)} vs ${statsEsc(m.away_team)}, ${matchDateKey}" title="Chain visualizer: ${statsEsc(m.home_team)} vs ${statsEsc(m.away_team)}, ${matchDateKey}" data-nav-url="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 }

  // Same normalizer the card-render side uses — keeps the lookup key in sync.
  const _norm = window.normalizeTeam || (s => s)

  // Clear the cross-eval dedup sig on every (re)start so the next poll
  // always writes to _pollerSnapshot. Without this, an OJS re-eval here
  // resets the mutable to null but leaves window._matchesPollerSig from
  // the prior session — first poll matches, short-circuits, snapshot
  // stays null, Live tab silently dies.
  window._matchesPollerSig = null

  window.footballLivePoller.start({
    onScoreUpdate: (games) => {
      // Push the latest poll into the reactive snapshot so the Live tab
      // re-evaluates with fresh status / score. Dedupe via a JSON signature
      // so we don't churn downstream cells on polls that returned the same
      // data — the 60s football poll cadence means many ticks are no-ops.
      const compact = games.map(g => ({
        id: g.id, league: g.league, status: g.status,
        homeScore: g.homeScore, awayScore: g.awayScore
      }))
      // Dedupe via a window-scoped sig (NOT a read of `_pollerSnapshot`, since
      // that would make this OJS cell a dep on the mutable and the assignment
      // below would re-trigger the cell → restart the poller → infinite loop).
      const sig = JSON.stringify(compact)
      if (sig !== window._matchesPollerSig) {
        window._matchesPollerSig = sig
        const byId = new Map()
        for (const g of compact) if (g.id != null) byId.set(g.id, g)
        mutable _pollerSnapshot = { byId, _sig: sig, updated: Date.now() }
      }

      for (const g of games) {
        // Match by league|date|normalized homeTeam|normalized awayTeam
        const dateKey = (g.date || "").slice(0, 10)
        const matchId = `${g.league}|${dateKey}|${_norm(g.homeTeam)}|${_norm(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. Lazily create .match-info if the card had no
        // kickoff/venue text on first paint — without this, going-live mid-page
        // would silently drop the badge.
        let info = card.querySelector(".match-info")
        if (!info && isLive) {
          info = document.createElement("div")
          info.className = "match-info"
          card.prepend(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()
    // Reset sig too — next re-eval should re-publish even if data hasn't
    // changed (the mutable will be back to null and the consumer is waiting).
    window._matchesPollerSig = null
  })
  return "polling"
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  // Last Updated
  const upd = railBlock("Status")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "Live polling during games"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Pre-match picks refresh daily from the "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "pannadata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline. Live WP updates every minute during fixtures."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN — real data from predictions parquet (which loads on this page).
  // Filter out rows where home_win_prob is null (past fixtures, or rows
  // where the model hasn't produced a forecast) — those would otherwise
  // fall through with ?? 0.5 defaults and show as "50% favourite", which
  // is the bug Pete caught.
  const btn = railBlock("This Matchday at a Glance")
  const grid = document.createElement("div"); grid.className = "btn-block"

  const preds = (Array.isArray(predictions) ? predictions : [])
    .filter(p => p && p.home_win_prob != null)

  // Compute the maximum outcome probability for a match (H / D / A).
  // Used both for "most balanced game" (smallest max → most uncertain) and
  // for "biggest favourite" (largest max → most decisive pick).
  function maxOutcome(p) {
    const hwp = p.home_win_prob
    const drw = p.draw_prob ?? 0
    const awp = Math.max(0, 1 - hwp - drw)
    return Math.max(hwp, drw, awp)
  }

  if (preds.length > 0) {
    const sortedByCert = [...preds].sort((a, b) => maxOutcome(a) - maxOutcome(b))
    const closest = sortedByCert[0]  // smallest max-prob → most uncertain
    const lopsided = sortedByCert[sortedByCert.length - 1]  // largest max-prob → biggest favourite
    const drawLikely = [...preds].sort((a, b) => (b.draw_prob ?? 0) - (a.draw_prob ?? 0))[0]

    // Most balanced — only show if the toss-up is genuinely below 50%
    if (closest && maxOutcome(closest) < 0.50) {
      const closestMax = (maxOutcome(closest) * 100).toFixed(0)
      grid.appendChild(btnTile(closestMax + "%", [
        { text: "Most uncertain game", bold: true },
        { text: " · " + (closest.home_team || "—") + " vs " + (closest.away_team || "—") },
        { br: true }, { text: "no outcome above " + closestMax + "%" }
      ]))
    }

    // Biggest favourite — only show if a real favourite (>= 60%)
    if (lopsided && maxOutcome(lopsided) >= 0.60) {
      const hwp = lopsided.home_win_prob
      const awp = Math.max(0, 1 - hwp - (lopsided.draw_prob ?? 0))
      const homeFav = hwp >= awp
      const favTeam = homeFav ? lopsided.home_team : lopsided.away_team
      const oppTeam = homeFav ? lopsided.away_team : lopsided.home_team
      const favPct = (maxOutcome(lopsided) * 100).toFixed(0)
      grid.appendChild(btnTile(favPct + "%", [
        { text: "Biggest favourite", bold: true },
        { text: " · " + (favTeam || "—") },
        { br: true }, { text: "over " + (oppTeam || "—") }
      ]))
    }

    // Most likely draw — only show if meaningfully above the baseline (>= 30%)
    if (drawLikely && (drawLikely.draw_prob ?? 0) >= 0.30) {
      grid.appendChild(btnTile(((drawLikely.draw_prob) * 100).toFixed(0) + "%", [
        { text: "Most likely draw", bold: true },
        { text: " · " + (drawLikely.home_team || "—") + " vs " + (drawLikely.away_team || "—") }
      ]))
    }
  }

  const leaguesInPreds = new Set(preds.map(p => p.league).filter(Boolean)).size
  grid.appendChild(btnTile(String(leaguesInPreds || 15), [
    { text: "Leagues covered", bold: true },
    { text: " · Europe + UCL + UEL" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // About
  const about = railBlock("Reading the picks"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Pre-match probabilities come from "))
  const s1 = document.createElement("strong"); s1.textContent = "Panna"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" — squad strengths from the top-20 players' ratings."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Finished matches surface "))
  const s2 = document.createElement("strong"); s2.textContent = "xG"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" alongside the score — the chance-quality story of the game. Live fixtures show in-play WP with a draw segment."))
  about.appendChild(p2)
  inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "leagues.html", title: "Leagues & Sims", meta: "Projected final standings" },
    { href: "team-ratings.html", title: "Team Ratings", meta: "What drives the picks" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Player-level Panna" },
    { href: "../blog/2026-04-24-understanding-panna/", title: "Understanding Panna", meta: "Blog · Methodology" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer