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

AFL Matches

Skip to content

AFL > Matches

AFL · Match Predictions · TORP Model

Who wins this weekend?

Round-by-round picks driven by TORP — team strengths derived from every player’s contribution, weighted by their predicted minutes. Live games refresh in-play; finished matches surface the shot-quality story (xS) underneath.

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-torp/">Methodology &darr;</a></span>
  <span><a href="ladder.html">Ladder &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

fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL

predictions = fetchParquet(base_url + "afl/predictions.parquet")

fixtures = {
  const data = await window.fetchFixtures("afl")
  return data ? (data.games || null) : null
}

// Team name mappings (from shared afl/team-maps.js)
predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}
predToAbbr = window.aflTeamMaps?.predToAbbr || {}
squiggleToPred = window.aflTeamMaps?.squiggleToPred || {}
aflTeamColors = window.aflTeamMaps?.aflTeamColors || {}
teamLogo = window.aflTeamMaps?.teamLogo || (() => null)

// Resolve any team name format to pred name or abbreviation
toPred = (name) => fullToPred[name] || (predToFull[name] ? name : null) || name
teamToAbbr = (name) => predToAbbr[toPred(name)] || name

// Build a lookup: "round|home_full|away_full" → fixture object
// Normalize Squiggle names → canonical full names via predToFull
fixtureMap = {
  if (!fixtures) return {}
  const map = {}
  for (const f of fixtures) {
    const h = predToFull[squiggleToPred[f.hteam] || f.hteam] || predToFull[f.hteam] || f.hteam
    const a = predToFull[squiggleToPred[f.ateam] || f.ateam] || predToFull[f.ateam] || f.ateam
    map[`${f.round}|${h}|${a}`] = f
  }
  return map
}

lookupFixture = (round, home, away) => {
  const h = predToFull[home] || home
  const a = predToFull[away] || away
  return fixtureMap[`${round}|${h}|${a}`]
}
Show code
currentYear = new Date().getFullYear()

seasons = {
  if (!predictions) return [currentYear]
  const raw = [...new Set(predictions.map(d => d.season))].filter(s => s != null)
  return raw.length > 0 ? raw.sort((a, b) => b - a) : [currentYear]
}

defaultSeason = {
  if (seasons.includes(currentYear)) return currentYear
  if (seasons.includes(currentYear - 1)) return currentYear - 1
  return seasons[0]
}

viewof selectedSeason = predictions == null
  ? html`<p></p>`
  : Inputs.select(seasons, {
      value: defaultSeason,
      format: d => `${d}`,
      label: "Season"
    })

seasonPreds = {
  if (!predictions || selectedSeason == null) return []
  return predictions.filter(d => d.season === selectedSeason)
}

// Fixtures are only relevant for the current (max) season
isCurrentSeason = seasons.length > 0 && selectedSeason === seasons[0]
seasonFixtures = isCurrentSeason ? fixtures : null
Show code
// Round / Results / Fixtures toggle
viewof matchView = {
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = "Round"
  for (const label of ["Round", "Results", "Fixtures"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "Round" ? " 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
roundLabels = {
  if (seasonPreds.length === 0 && !seasonFixtures) return []
  const predRounds = seasonPreds.map(d => d.round)
  const fixRounds = seasonFixtures ? seasonFixtures.map(d => d.round) : []
  const rounds = [...new Set([...predRounds, ...fixRounds])].sort((a, b) => a - b)
  return rounds.map(r => ({
    value: r,
    label: r === 0 ? "Opening Round" : `Round ${r}`
  }))
}

// Helper: check if a match is finished (using both predictions AND fixture data)
_isFinished = (m, round) => {
  if (m.actual_margin != null && !isNaN(m.actual_margin)) return true
  const f = lookupFixture(round, m.home_team, m.away_team)
  return f && f.complete === 100
}

defaultRound = {
  if (roundLabels.length === 0) return 0
  const allRounds = roundLabels.map(r => r.value)

  // Results/Fixtures default to "All Rounds"
  if (matchView === "Results" || matchView === "Fixtures") return "all"

  // Round tab: first round with at least one unfinished game
  let foundRound = allRounds[allRounds.length - 1]
  for (const r of allRounds) {
    const preds = seasonPreds.filter(d => d.round === r)
    if (preds.length > 0) {
      if (preds.some(d => !_isFinished(d, r))) { foundRound = r; break }
    } else if (seasonFixtures) {
      const fixMatches = seasonFixtures.filter(d => d.round === r)
      if (fixMatches.some(d => d.complete < 100)) { foundRound = r; break }
    }
  }

  // Tuesday switchover: on Tue/Wed, if current round mostly complete, advance
  const day = new Date().getDay()
  if (day === 2 || day === 3) {
    const preds = seasonPreds.filter(d => d.round === foundRound)
    const finished = preds.filter(d => _isFinished(d, foundRound)).length
    if (preds.length > 0 && finished >= preds.length - 1) {
      const next = allRounds.find(r => r > foundRound)
      if (next != null) foundRound = next
    }
  }

  return foundRound
}

// Round dropdown options: prepend "All Rounds" for Results/Fixtures
roundOptions = {
  if (matchView === "Results" || matchView === "Fixtures") {
    return [{ value: "all", label: "All Rounds" }, ...roundLabels]
  }
  return roundLabels
}

viewof selectedRound = roundOptions.length === 0
  ? html`<p></p>`
  : Inputs.select(roundOptions, {
      value: roundOptions.find(r => r.value === defaultRound),
      format: d => d.label,
      label: "Round"
    })
Show code
roundMatches = {
  if (!selectedRound) return null
  const roundValue = selectedRound.value

  // "All Rounds" mode for Results/Fixtures
  if (roundValue === "all") {
    let filtered = seasonPreds
      .filter(m => {
        const finished = _isFinished(m, m.round)
        return matchView === "Results" ? finished : !finished
      })

    // Include fixture-only rounds (no predictions yet)
    if (seasonFixtures) {
      const predRounds = new Set(seasonPreds.map(d => `${d.round}|${predToFull[d.home_team] || d.home_team}|${predToFull[d.away_team] || d.away_team}`))
      const fixtureOnly = seasonFixtures
        .filter(f => {
          const h = predToFull[squiggleToPred[f.hteam] || f.hteam] || predToFull[f.hteam] || f.hteam
          const a = predToFull[squiggleToPred[f.ateam] || f.ateam] || predToFull[f.ateam] || f.ateam
          if (predRounds.has(`${f.round}|${h}|${a}`)) return false
          return matchView === "Results" ? f.complete === 100 : f.complete < 100
        })
        .map(f => ({
          home_team: predToFull[squiggleToPred[f.hteam] || f.hteam] || predToFull[f.hteam] || f.hteam,
          away_team: predToFull[squiggleToPred[f.ateam] || f.ateam] || predToFull[f.ateam] || f.ateam,
          round: f.round,
          fixture: f,
          fixtureOnly: true
        }))
      filtered = [...filtered, ...fixtureOnly]
    }

    // Results: most recent round first; Fixtures: next round first
    const dir = matchView === "Results" ? -1 : 1
    filtered.sort((a, b) => {
      if (a.round !== b.round) return (a.round - b.round) * dir
      const fa = a.fixture || lookupFixture(a.round, a.home_team, a.away_team)
      const fb = b.fixture || lookupFixture(b.round, b.home_team, b.away_team)
      if (fa && fb) return (fa.unixtime || 0) - (fb.unixtime || 0)
      return 0
    })
    return filtered
  }

  // Single round mode
  const round = roundValue
  const predMatches = seasonPreds.filter(d => d.round === round)

  // For fixture-only rounds, build cards from Squiggle data
  if (predMatches.length === 0 && seasonFixtures) {
    const fixMatches = seasonFixtures.filter(d => d.round === round)
      .sort((a, b) => (a.unixtime || 0) - (b.unixtime || 0))
    if (fixMatches.length > 0) return fixMatches.map(f => ({
      home_team: predToFull[squiggleToPred[f.hteam] || f.hteam] || predToFull[f.hteam] || f.hteam,
      away_team: predToFull[squiggleToPred[f.ateam] || f.ateam] || predToFull[f.ateam] || f.ateam,
      round: f.round,
      fixture: f,
      fixtureOnly: true
    }))
  }

  // Round view shows all; Results/Fixtures filter by status
  const filtered = matchView === "Round"
    ? predMatches
    : predMatches.filter(m => {
        const finished = _isFinished(m, round)
        return matchView === "Results" ? finished : !finished
      })

  // Sort by fixture kickoff time when available
  return filtered.sort((a, b) => {
    const fa = lookupFixture(round, a.home_team, a.away_team)
    const fb = lookupFixture(round, b.home_team, b.away_team)
    if (fa && fb) return (fa.unixtime || 0) - (fb.unixtime || 0)
    return Math.abs(b.pred_margin) - Math.abs(a.pred_margin)
  })
}

formatMatchDate = (dateStr) => window.formatMatchDate(dateStr, "short")

// Team form from played matches this season (stores result + match link)
aflTeamForm = {
  if (!seasonPreds) return {}
  const form = {}
  const played = seasonPreds
    .filter(d => _isFinished(d, d.round))
    .sort((a, b) => (a.round || 0) - (b.round || 0))
  for (const m of played) {
    const hKey = m.home_team
    const aKey = m.away_team
    if (!form[hKey]) form[hKey] = []
    if (!form[aKey]) form[aKey] = []
    const url = `match#season=${selectedSeason}&round=${m.round}&home=${teamToAbbr(m.home_team)}&away=${teamToAbbr(m.away_team)}`
    // Check actual_margin first, fall back to fixture scores
    let hResult, aResult, hScore, aScore
    const homeFull = predToFull[m.home_team] || m.home_team
    const awayFull = predToFull[m.away_team] || m.away_team
    const f = lookupFixture(m.round, m.home_team, m.away_team)
    if (m.actual_margin != null && !isNaN(m.actual_margin)) {
      hResult = m.actual_margin > 0 ? "W" : m.actual_margin < 0 ? "L" : "D"
      aResult = m.actual_margin > 0 ? "L" : m.actual_margin < 0 ? "W" : "D"
      hScore = f?.hscore ?? (m.pred_total != null ? Math.round((m.pred_total + m.actual_margin) / 2) : null)
      aScore = f?.ascore ?? (m.pred_total != null ? Math.round((m.pred_total - m.actual_margin) / 2) : null)
    } else if (f && f.hscore != null) {
      hResult = f.hscore > f.ascore ? "W" : f.hscore < f.ascore ? "L" : "D"
      aResult = f.hscore > f.ascore ? "L" : f.hscore < f.ascore ? "W" : "D"
      hScore = f.hscore; aScore = f.ascore
    }
    if (hResult) {
      const hAbbr = teamToAbbr(m.home_team), aAbbr = teamToAbbr(m.away_team)
      const scoreStr = hScore != null ? ` ${hScore}–${aScore}` : ""
      form[hKey].push({ result: hResult, url, tip: `${hAbbr} v ${aAbbr}${scoreStr} ${hResult}` })
      form[aKey].push({ result: aResult, url, tip: `${hAbbr} v ${aAbbr}${scoreStr} ${aResult}` })
    }
  }
  for (const k of Object.keys(form)) form[k] = form[k].slice(-5)
  return form
}

aflFormDots = (team) => {
  const results = aflTeamForm[team]
  if (!results || results.length === 0) return ""
  return `<span class="form-dots">${results.map(r =>
    `<span class="form-dot form-${statsEsc(String(r.result || "").toLowerCase())}" data-tip="${statsEsc(r.tip || "")}" aria-label="${statsEsc(r.tip || r.result || "match")}" data-nav-url="${statsEsc(r.url || "")}" role="link" tabindex="0"></span>`
  ).join("")}</span>`
}

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

  const showForm = matchView !== "Results"
  const allRoundsMode = selectedRound.value === "all"

  const badge = (name) => {
    const src = teamLogo(name)
    return src ? `<img src="${src}" alt="${statsEsc(name)}" class="team-badge" role="link" tabindex="0" aria-label="Open ${statsEsc(name)} team page" title="${statsEsc(name)} team page" data-nav-url="team#team=${encodeURIComponent(name)}">` : ""
  }

  const buildCard = (m, round) => {
    const homeName = predToFull[m.home_team] || m.home_team
    const awayName = predToFull[m.away_team] || m.away_team
    const matchUrl = `match#season=${selectedSeason}&round=${round}&home=${teamToAbbr(m.home_team)}&away=${teamToAbbr(m.away_team)}`

    // Fixture-only card (no TORP predictions yet)
    if (m.fixtureOnly) {
      const f = m.fixture
      const played = f.complete === 100
      const isLive = f.complete > 0 && f.complete < 100
      const liveClass = isLive ? " is-live" : ""
      const liveBadge = isLive ? `<span class="live-badge">LIVE</span>` : ""
      const liveQuarter = isLive && f.timestr ? `<div class="match-quarter">${statsEsc(f.timestr)}</div>` : ""
      const scoreHtml = (played || isLive)
        ? `<div class="match-score">${f.hscore ?? 0} – ${f.ascore ?? 0}${liveQuarter}</div>`
        : ""
      return `
        <a href="${matchUrl}" class="match-card-link">
        <div class="match-card fixture-only${liveClass}" data-match-id="${f.id}">
          <div class="match-info">${liveBadge}${statsEsc(formatMatchDate(f.date))}${f.venue ? ` · ${statsEsc(f.venue)}` : ""}</div>
          <div class="match-teams">
            <div class="team">${badge(homeName)}<span class="team-link" role="link" tabindex="0" data-nav-url="team#team=${encodeURIComponent(homeName)}">${statsEsc(homeName)}</span></div>
            <div class="match-vs">${(played || isLive) ? scoreHtml : "vs"}</div>
            <div class="team">${badge(awayName)}<span class="team-link" role="link" tabindex="0" data-nav-url="team#team=${encodeURIComponent(awayName)}">${statsEsc(awayName)}</span></div>
          </div>
        </div>
        </a>`
    }

    // Standard prediction card — enrich with fixture data
    const f = lookupFixture(m.round, m.home_team, m.away_team)
    const homeWin = m.pred_margin > 0
    const played = m.actual_margin != null && !isNaN(m.actual_margin)
    const isLive = f && f.complete > 0 && f.complete < 100
    const liveClass = isLive ? " is-live" : ""
    const liveBadge = isLive ? `<span class="live-badge">LIVE</span>` : ""
    const matchIdAttr = f ? ` data-match-id="${f.id}"` : ""
    const xsAttr = (m.xscore_home != null && m.xscore_away != null)
      ? ` data-xs-home="${m.xscore_home.toFixed(0)}" data-xs-away="${m.xscore_away.toFixed(0)}"` : ""
    const infoHtml = f
      ? `<div class="match-info">${liveBadge}${statsEsc(formatMatchDate(f.date))}${f.venue ? ` · ${statsEsc(f.venue)}` : ""}</div>`
      : ""
    const squigglePlayed = f && f.complete === 100
    const isFinished = played || squigglePlayed
    const hasXs = m.xscore_home != null && m.xscore_away != null

    const predHome = m.pred_total != null && m.pred_margin != null ? Math.round((m.pred_total + m.pred_margin) / 2) : null
    const predAway = m.pred_total != null && m.pred_margin != null ? Math.round((m.pred_total - m.pred_margin) / 2) : null

    let scoreHtml = "vs"
    if (isLive) {
      const liveQuarter = f.timestr ? `<div class="match-quarter">${statsEsc(f.timestr)}</div>` : ""
      scoreHtml = `<div class="match-score">${f.hscore ?? 0} – ${f.ascore ?? 0}${liveQuarter}</div>`
    } else if (squigglePlayed) {
      const xsLine = hasXs ? `<div class="match-xg">xS: ${m.xscore_home.toFixed(0)} – ${m.xscore_away.toFixed(0)}</div>` : ""
      scoreHtml = `<div class="match-score">${f.hscore} – ${f.ascore}${xsLine}</div>`
    } else if (played) {
      const homeScore = m.pred_total != null ? Math.round((m.pred_total + m.actual_margin) / 2) : null
      const awayScore = m.pred_total != null ? Math.round((m.pred_total - m.actual_margin) / 2) : null
      const xsLine = hasXs ? `<div class="match-xg">xS: ${m.xscore_home.toFixed(0)} – ${m.xscore_away.toFixed(0)}</div>` : ""
      if (homeScore != null && awayScore != null) {
        scoreHtml = `<div class="match-score">${homeScore} – ${awayScore}${xsLine}</div>`
      }
    }

    const dualBar = isFinished && hasXs
    let xsBarHtml = ""
    if (dualBar) {
      const xsMargin = m.xscore_home - m.xscore_away
      const xsHomeProb = 1 / (1 + Math.exp(-xsMargin / 20))
      const xsHomePct = (xsHomeProb * 100).toFixed(0)
      const xsAwayPct = ((1 - xsHomeProb) * 100).toFixed(0)
      xsBarHtml = `
        <div class="prob-bar-row">
          <span class="prob-bar-label">xS</span>
          <div class="prob-bar-container">
            <div class="prob-bar prob-home" style="width: ${xsHomeProb * 100}%">
              ${xsHomeProb >= 0.15 ? xsHomePct + '%' : ''}
            </div>
            <div class="prob-bar prob-away" style="width: ${(1 - xsHomeProb) * 100}%">
              ${(1 - xsHomeProb) >= 0.15 ? xsAwayPct + '%' : ''}
            </div>
          </div>
        </div>`
    }

    let liveWpBarHtml = ""
    if (isLive && window.aflMatchProbs) {
      const probs = window.aflMatchProbs(f.timestr, f.complete, f.hscore, f.ascore)
      const hPct = (probs.home * 100).toFixed(0)
      const dPct = (probs.draw * 100).toFixed(0)
      const aPct = (probs.away * 100).toFixed(0)
      const showDraw = probs.draw >= 0.02
      liveWpBarHtml = `
        <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.15 ? hPct + '%' : ''}
            </div>
            ${showDraw ? `<div class="prob-bar prob-draw" style="width: ${probs.draw * 100}%">
              ${probs.draw >= 0.05 ? dPct + '%' : ''}
            </div>` : ''}
            <div class="prob-bar prob-away" style="width: ${(showDraw ? probs.away : 1 - probs.home) * 100}%">
              ${probs.away >= 0.15 ? aPct + '%' : ''}
            </div>
          </div>
        </div>`
    }

    const homeRating = m.pred_total != null ? `<span class="rating">${predHome}</span>` : ""
    const awayRating = m.pred_total != null ? `<span class="rating">${predAway}</span>` : ""

    return `
      <a href="${matchUrl}" class="match-card-link">
      <div class="match-card${liveClass}"${matchIdAttr}${xsAttr}>
        ${infoHtml}
        <div class="match-teams">
          <div class="team ${homeWin ? 'favoured' : ''}">${badge(homeName)}<span class="team-link" role="link" tabindex="0" data-nav-url="team#team=${encodeURIComponent(homeName)}">${statsEsc(homeName)}</span>
            ${homeRating}
            ${showForm ? aflFormDots(m.home_team) : ""}
          </div>
          <div class="match-vs">${scoreHtml}</div>
          <div class="team ${!homeWin ? 'favoured' : ''}">${badge(awayName)}<span class="team-link" role="link" tabindex="0" data-nav-url="team#team=${encodeURIComponent(awayName)}">${statsEsc(awayName)}</span>
            ${awayRating}
            ${showForm ? aflFormDots(m.away_team) : ""}
          </div>
        </div>
        <div class="match-prediction">
          <div class="prob-bars-group">
            <div class="prob-bar-row">
              ${(dualBar || isLive) ? '<span class="prob-bar-label">TORP</span>' : ''}
              <div class="prob-bar-container">
                <div class="prob-bar prob-home" style="width: ${m.home_win_prob * 100}%">
                  ${m.home_win_prob >= 0.15 ? (m.home_win_prob * 100).toFixed(0) + '%' : ''}
                </div>
                <div class="prob-bar prob-away" style="width: ${(1 - m.home_win_prob) * 100}%">
                  ${(1 - m.home_win_prob) >= 0.15 ? ((1 - m.home_win_prob) * 100).toFixed(0) + '%' : ''}
                </div>
              </div>
            </div>
            ${xsBarHtml}
            ${liveWpBarHtml}
          </div>
        </div>
        <div class="match-card-footer">
          ${(() => {
            if (m.pred_margin == null) return ""
            const margin = Math.abs(m.pred_margin).toFixed(1)
            const favTeam = homeWin ? statsEsc(homeName) : statsEsc(awayName)
            const predLine = m.pred_margin === 0
              ? `<span class="pred-line">Prediction: <strong>Draw</strong></span>`
              : `<span class="pred-line">Prediction: <strong>${favTeam}</strong> by ${margin} pts</span>`
            // Result line: only for finished games where we have scores
            let resultLine = ""
            const rh = (f && f.hscore != null) ? f.hscore
              : (played && m.pred_total != null) ? Math.round((m.pred_total + m.actual_margin) / 2) : null
            const ra = (f && f.ascore != null) ? f.ascore
              : (played && m.pred_total != null) ? Math.round((m.pred_total - m.actual_margin) / 2) : null
            if (isFinished && rh != null && ra != null) {
              const draw = rh === ra
              const hWin = rh > ra
              const cls = draw ? "incorrect" : (hWin === homeWin ? "correct" : "incorrect")
              resultLine = `<span class="actual-result ${cls}">Result: ${draw ? "Draw" : `${rh} – ${ra}`}</span>`
            }
            return `<div class="pred-detail">${predLine}${resultLine}</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)}, round ${round}" title="Chain visualizer: ${statsEsc(m.home_team)} vs ${statsEsc(m.away_team)}, round ${round}" data-nav-url="match-chains.html#season=${selectedSeason}&round=${round}&home=${teamToAbbr(m.home_team)}&away=${teamToAbbr(m.away_team)}">Chains →</span></div>
        </div>
      </div>
      </a>`
  }

  // Build cards with round group separators in all-rounds mode
  const parts = []
  let lastRound = null
  for (const m of roundMatches) {
    const round = m.round ?? selectedRound.value
    if (allRoundsMode && round !== lastRound) {
      const label = round === 0 ? "Opening Round" : `Round ${round}`
      parts.push(`<div class="round-group-header">${label}</div>`)
      lastRound = round
    }
    parts.push(buildCard(m, round))
  }

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

  window.aflLivePoller.start({
    onScoreUpdate: (games) => {
      for (const g of games) {
        const card = document.querySelector(`[data-match-id="${g.id}"]`)
        if (!card) continue

        const isLive = g.complete > 0 && g.complete < 100
        const isFinished = g.complete === 100

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

        // Update LIVE badge in match-info
        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 = "LIVE"
            info.prepend(badge)
          } else if (!isLive && existingBadge) {
            existingBadge.remove()
          }
        }

        // Update score in match-vs
        const vs = card.querySelector(".match-vs")
        if (vs && (isLive || isFinished)) {
          // Clear existing content safely
          while (vs.firstChild) vs.removeChild(vs.firstChild)
          const scoreDiv = document.createElement("div")
          scoreDiv.className = "match-score"
          scoreDiv.textContent = `${g.hscore ?? 0} – ${g.ascore ?? 0}`
          if (isLive && g.timestr) {
            const qtr = document.createElement("div")
            qtr.className = "match-quarter"
            qtr.textContent = g.timestr
            scoreDiv.appendChild(qtr)
          }
          // Preserve xS line for finished matches (xS values stashed at render time)
          const xsHome = card.dataset.xsHome
          const xsAway = card.dataset.xsAway
          if (isFinished && xsHome != null && xsAway != null) {
            const xs = document.createElement("div")
            xs.className = "match-xg"
            xs.textContent = `xS: ${xsHome} – ${xsAway}`
            scoreDiv.appendChild(xs)
          }
          vs.appendChild(scoreDiv)
        }

        // Update live win probability bar (3-segment: home|draw|away)
        const wpBar = card.querySelector("[data-wp-bar]")
        if (isLive && window.aflMatchProbs) {
          const probs = window.aflMatchProbs(g.timestr, g.complete, g.hscore, g.ascore)
          if (wpBar) {
            const container = wpBar.querySelector(".prob-bar-container")
            if (container) {
              const hBar = container.querySelector(".prob-home")
              const dBar = container.querySelector(".prob-draw")
              const aBar = container.querySelector(".prob-away")
              if (hBar) { hBar.style.width = (probs.home * 100) + "%"; hBar.textContent = probs.home >= 0.15 ? (probs.home * 100).toFixed(0) + "%" : "" }
              if (aBar) { aBar.style.width = (probs.away * 100) + "%"; aBar.textContent = probs.away >= 0.15 ? (probs.away * 100).toFixed(0) + "%" : "" }
              if (probs.draw >= 0.02) {
                if (dBar) {
                  dBar.style.width = (probs.draw * 100) + "%"
                  dBar.textContent = probs.draw >= 0.05 ? (probs.draw * 100).toFixed(0) + "%" : ""
                } else {
                  // Insert draw segment between home and away
                  const newDraw = document.createElement("div")
                  newDraw.className = "prob-bar prob-draw"
                  newDraw.style.width = (probs.draw * 100) + "%"
                  if (probs.draw >= 0.05) newDraw.textContent = (probs.draw * 100).toFixed(0) + "%"
                  container.insertBefore(newDraw, aBar)
                }
              } else if (dBar) {
                dBar.remove()
              }
            }
          }
        } else if (!isLive && wpBar) {
          wpBar.remove()
        }
      }
    }
  })

  // Cleanup when cell is invalidated (round/season change)
  invalidation.then(() => window.aflLivePoller.stop())
  return "polling"
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  if (!predictions || predictions.length === 0) {
    const lb = railBlock("Loading")
    const p = document.createElement("p")
    p.style.cssText = "color: var(--site-muted-color); font-size: 0.85rem; font-family: 'Source Serif 4', Georgia, serif; margin: 0;"
    p.textContent = "Resolving the latest predictions…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  // Find current season + REAL current round from fixtures (predictions parquet
  // contains the whole season, so max round is end-of-season, not "now").
  let maxSeason = -Infinity
  for (const m of predictions) { if (m.season > maxSeason) maxSeason = m.season }
  const seasonPreds = predictions.filter(m => m.season === maxSeason)

  // Derive current round: minimum round among NOT-yet-complete fixtures this
  // season (= the next round still to be played). If everything is complete,
  // fall back to the max round (end-of-season recap).
  const fxThisSeason = (fixtures || []).filter(f => (f.year ?? f.season) === maxSeason || !f.year)
  let curRound = null
  let nextUnplayed = Infinity
  let maxPlayed = -Infinity
  for (const f of fxThisSeason) {
    if (f.round == null) continue
    if ((f.complete ?? 0) < 100) {
      if (f.round < nextUnplayed) nextUnplayed = f.round
    } else {
      if (f.round > maxPlayed) maxPlayed = f.round
    }
  }
  curRound = nextUnplayed !== Infinity ? nextUnplayed : (maxPlayed !== -Infinity ? maxPlayed : null)
  const roundPreds = curRound != null
    ? seasonPreds.filter(m => m.round === curRound)
    : seasonPreds

  // Filter to non-null home_win_prob before sorting. Using `?? 0.5` inside
  // a comparator silently widens the dataset with null-as-50% rows and the
  // sort arbitrarily picks one — same trap that bit football/matches.
  const validPreds = roundPreds.filter(p => p && p.home_win_prob != null)
  const closest = [...validPreds].sort((a, b) =>
    Math.abs(a.home_win_prob - 0.5) - Math.abs(b.home_win_prob - 0.5)
  )[0]
  const lopsided = [...validPreds].sort((a, b) =>
    Math.abs(b.home_win_prob - 0.5) - Math.abs(a.home_win_prob - 0.5)
  )[0]

  function shortMatchup(m) {
    if (!m) return "—"
    const abbrMap = window.aflTeamMaps?.predToAbbr || {}
    const h = abbrMap[m.home_team] || m.home_team
    const a = abbrMap[m.away_team] || m.away_team
    return h + " v " + a
  }
  const closestHpct = closest ? (closest.home_win_prob * 100).toFixed(0) : null
  const lopHpct = lopsided ? (Math.max(lopsided.home_win_prob, 1 - lopsided.home_win_prob) * 100).toFixed(0) : null
  const lopHomeFav = lopsided && lopsided.home_win_prob >= 0.5
  const lopFavTeam = lopsided ? (lopHomeFav ? lopsided.home_team : lopsided.away_team) : ""
  const lopUndTeam = lopsided ? (lopHomeFav ? lopsided.away_team : lopsided.home_team) : ""

  // Count completed vs total this round
  const completedThisRound = curRound != null
    ? fxThisSeason.filter(f => f.round === curRound && (f.complete ?? 0) >= 100).length
    : 0
  const totalThisRound = curRound != null
    ? fxThisSeason.filter(f => f.round === curRound).length
    : roundPreds.length

  // 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-game 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 = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline. Live WP updates every minute during games."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN — focus on the current round, computed from fixture completion state
  const btn = railBlock("This Round at a Glance")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile(curRound != null ? "R" + curRound : "—", [
    { text: "Round", bold: true },
    { text: " · " + completedThisRound + " of " + totalThisRound + " played" }
  ]))
  // Sanity thresholds: only show tiles when they reveal something.
  // Closest only if within 10pp of even (40-60%). Favourite only if >= 60%.
  if (closest && Math.abs(closest.home_win_prob - 0.5) <= 0.10) {
    grid.appendChild(btnTile(closestHpct + "%", [
      { text: "Closest pick", bold: true },
      { text: " · " + shortMatchup(closest) }
    ]))
  }
  if (lopsided && lopFavTeam && Number(lopHpct) >= 60) {
    grid.appendChild(btnTile(lopHpct + "%", [
      { text: "Biggest favourite", bold: true },
      { text: " · " + lopFavTeam },
      { br: true }, { text: "over " + lopUndTeam }
    ]))
  }
  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-game probabilities come from "))
  const s1 = document.createElement("strong"); s1.textContent = "TORP"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" — team strengths from the latest player ratings, weighted by predicted minutes."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Finished matches surface "))
  const s2 = document.createElement("strong"); s2.textContent = "xS"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" (shot-quality WP) — what the underlying chances said the game should have been worth. Live games 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: "ladder.html", title: "Ladder & Sims", meta: "Season projections" },
    { href: "team-ratings.html", title: "Team Ratings", meta: "What drives the picks" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Player-level TORP" },
    { href: "../blog/2026-04-24-understanding-torp/", title: "Understanding TORP", 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