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 Team Stats

Skip to content

Football > Team Stats

Football · Team Stats · Aggregated Box Score

Which clubs are really pulling their weight?

Match-by-match team stats rolled up across 15 leagues: goals scored, shots, key passes, xG, possession. Filter by league, season, or matchday to see who’s over- or under-performing their underlying numbers.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

// League code -> display name mapping
leagueCodes = window.footballMaps.clubLeagues
leagueNames = window.footballMaps.leagueNames

// Load league xG for enriching Results tab
_leagueXg = {
  try { return await window.fetchParquet(base + "football/league-xg.parquet") } catch (e) { console.warn("[team-stats] league-xg.parquet load failed:", e.message); return null }
}
Show code
statDefs = ({
  results: {
    label: "Results",
    columns: ["w", "l", "d", "win_pct", "gf", "ga", "gd", "xgf", "xga", "xgd"],
    mobileCols: ["w", "l", "win_pct", "gf", "ga", "xgd"],
    header: { w: "W", l: "L", d: "D", win_pct: "Win%",
              gf: "GF", ga: "GA", gd: "GD", xgf: "xGF", xga: "xGA", xgd: "xGD" },
    tooltip: { win_pct: "Win percentage", gf: "Goals for", ga: "Goals against", gd: "Goal difference",
               xgf: "Expected goals for (from shot quality)", xga: "Expected goals against", xgd: "Expected goal difference" },
    heatmap: { win_pct: "high-good", gd: "high-good", gf: "high-good", ga: "low-good", xgf: "high-good", xga: "low-good", xgd: "high-good" },
    sortCol: "win_pct"
  },
  scoring: {
    label: "Scoring",
    columns: ["goals", "assists", "shots", "shots_on_target", "shots_on_target_pct", "big_chances_created", "key_passes"],
    header: {
      goals: "Goals", assists: "Assists", shots: "Shots",
      shots_on_target: "On Target", shots_on_target_pct: "On-Target %",
      big_chances_created: "Big Chances", key_passes: "Key Passes"
    },
    heatmap: {
      goals: "high-good", assists: "high-good", shots: "high-good",
      shots_on_target: "high-good", shots_on_target_pct: "high-good",
      big_chances_created: "high-good", key_passes: "high-good"
    },
    sortCol: "goals",
    compute: true
  },
  passing: {
    label: "Passing",
    columns: ["passes", "passes_accurate", "pass_pct", "key_passes", "big_chances_created"],
    header: {
      passes: "Passes", passes_accurate: "Accurate", pass_pct: "Pass %",
      key_passes: "Key Passes", big_chances_created: "Big Chances"
    },
    heatmap: {
      passes: "high-good", passes_accurate: "high-good", pass_pct: "high-good",
      key_passes: "high-good", big_chances_created: "high-good"
    },
    sortCol: "passes",
    compute: true
  },
  defending: {
    label: "Defending",
    columns: ["tackles", "tackles_won", "tackles_won_pct", "interceptions", "clearances", "aerials_won", "aerials_lost", "aerials_won_pct"],
    header: {
      tackles: "Tackles", tackles_won: "Tackles Won", tackles_won_pct: "Tackle %",
      interceptions: "Intercepts", clearances: "Clearances",
      aerials_won: "Aerials Won", aerials_lost: "Aerials Lost", aerials_won_pct: "Aerial %"
    },
    heatmap: {
      tackles: "high-good", tackles_won: "high-good", tackles_won_pct: "high-good",
      interceptions: "high-good", clearances: "high-good",
      aerials_won: "high-good", aerials_lost: "low-good", aerials_won_pct: "high-good"
    },
    sortCol: "tackles",
    compute: true
  },
  duels: {
    label: "Duels",
    columns: ["duels_won", "duels_lost", "duels_won_pct", "aerials_won", "aerials_lost", "aerials_won_pct", "was_fouled", "fouls"],
    header: {
      duels_won: "Duels Won", duels_lost: "Duels Lost", duels_won_pct: "Duel %",
      aerials_won: "Aerials Won", aerials_lost: "Aerials Lost", aerials_won_pct: "Aerial %",
      was_fouled: "Fouled", fouls: "Fouls"
    },
    heatmap: {
      duels_won: "high-good", duels_lost: "low-good", duels_won_pct: "high-good",
      aerials_won: "high-good", aerials_lost: "low-good", aerials_won_pct: "high-good",
      was_fouled: "high-good", fouls: "low-good"
    },
    sortCol: "duels_won",
    compute: true
  },
  discipline: {
    label: "Discipline",
    columns: ["fouls", "was_fouled", "yellows", "reds", "dispossessed"],
    header: {
      fouls: "Fouls", was_fouled: "Fouled", yellows: "Yellows",
      reds: "Reds", dispossessed: "Dispossessed"
    },
    heatmap: {
      fouls: "low-good", yellows: "low-good", reds: "low-good",
      dispossessed: "low-good", was_fouled: "high-good"
    },
    sortCol: "fouls"
  },
  custom: {
    label: "Custom",
    columns: [],
    header: {},
    heatmap: {},
    sortCol: null
  }
})

catKeys = Object.keys(statDefs)
Show code
// ── Category toggle ──────────────────────────────────────────
viewof category = {
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "results"
  const _default = catKeys.includes(_saved) ? _saved : "results"
  const container = html`<div class="stats-category-toggle football"></div>`
  for (const key of catKeys) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (key === _default ? " active" : "")
    btn.textContent = statDefs[key].label
    btn.dataset.cat = key
    btn.addEventListener("click", () => {
      container.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = key
      window[_key] = key
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _default
  return container
}
Show code
// ── Custom column picker ─────────────────────────────────────
viewof customCols = {
  const allMetrics = []
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom") continue
    for (const col of def.columns) {
      if (allMetrics.some(m => m.col === col)) continue
      allMetrics.push({ col, label: def.header[col] || col, cat: def.label })
    }
  }

  const MAX = 10
  // Persist user's column choice across reloads via per-page localStorage key.
  const _lsKey = "_customCols_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _validCols = new Set(allMetrics.map(m => m.col))
  const _saved = (() => {
    try { const raw = window.localStorage.getItem(_lsKey); return raw ? JSON.parse(raw).filter(c => _validCols.has(c)).slice(0, MAX) : [] }
    catch (e) { return [] }
  })()
  const selected = new Set(_saved)

  const container = document.createElement("div")
  container.className = "custom-col-picker"

  const btn = document.createElement("button")
  btn.className = "custom-col-btn"
  btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
  container.appendChild(btn)

  const panel = document.createElement("div")
  panel.className = "custom-col-panel"
  panel.style.display = "none"
  container.appendChild(panel)

  const byCat = {}
  for (const m of allMetrics) {
    if (!byCat[m.cat]) byCat[m.cat] = []
    byCat[m.cat].push(m)
  }

  const checkboxes = []
  for (const [cat, metrics] of Object.entries(byCat)) {
    const group = document.createElement("div")
    group.className = "custom-col-group"
    const heading = document.createElement("div")
    heading.className = "custom-col-group-label"
    heading.textContent = cat
    group.appendChild(heading)

    for (const m of metrics) {
      const label = document.createElement("label")
      label.className = "custom-col-item"
      const cb = document.createElement("input")
      cb.type = "checkbox"
      cb.dataset.col = m.col
      if (selected.has(m.col)) cb.checked = true
      const span = document.createElement("span")
      span.textContent = m.label
      label.appendChild(cb)
      label.appendChild(span)
      group.appendChild(label)
      checkboxes.push(cb)

      cb.addEventListener("change", () => {
        if (cb.checked) selected.add(m.col)
        else selected.delete(m.col)
        for (const other of checkboxes) {
          if (!other.checked) other.disabled = selected.size >= MAX
        }
        btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
        container.value = [...selected]
        try { window.localStorage.setItem(_lsKey, JSON.stringify([...selected])) } catch (e) {}
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    }
    panel.appendChild(group)
  }
  if (selected.size >= MAX) {
    for (const cb of checkboxes) if (!cb.checked) cb.disabled = true
  }

  btn.addEventListener("click", (e) => {
    e.stopPropagation()
    panel.style.display = panel.style.display === "none" ? "block" : "none"
  })
  const ac = new AbortController()
  document.addEventListener("click", (e) => {
    if (!container.contains(e.target)) panel.style.display = "none"
  }, { signal: ac.signal })
  invalidation.then(() => ac.abort())

  container.value = [...selected]
  return container
}
Show code
// ── Toggle custom picker visibility ──────────────────────────
{
  const picker = document.querySelector(".custom-col-picker")
  if (picker) picker.style.display = category === "custom" ? "" : "none"
}
Show code
// ── Build effective catDef for custom tab ────────────────────
effectiveCatDef = {
  if (category !== "custom" || !customCols || customCols.length === 0) return catDef
  const header = {}, heatmap = {}
  for (const col of customCols) {
    for (const [key, def] of Object.entries(statDefs)) {
      if (key === "custom") continue
      if (def.columns.includes(col)) {
        header[col] = def.header[col] || col
        if (def.heatmap[col]) heatmap[col] = def.heatmap[col]
        break
      }
    }
  }
  return { label: "Custom", columns: customCols, header, heatmap, sortCol: customCols[0] || null }
}
Show code
viewof filters = {
  const makeSelect = window.footballMaps.makeSelect

  const container = document.createElement("div")
  container.className = "player-filter-bar"

  const row = document.createElement("div")
  row.className = "filter-row"

  // League select
  const leagueOpts = ["All Leagues", ...leagueCodes]
  const league = makeSelect(leagueOpts, "ENG", "League", x => x === "All Leagues" ? x : (leagueNames[x] || x))

  // Season select (populated after data loads)
  const season = makeSelect(["All Seasons"], "All Seasons", "Season")

  // Matchday select (populated after data loads)
  const matchday = makeSelect(["All Matchdays"], "All Matchdays", "Matchday")

  // Day of week (Mon-Sun)
  const daySelect = makeSelect(["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], "All Days", "Day")

  // Date range — populated reactively once matchStats loads. Defaults to the
  // played-game window of the selected season (see _updateDates handler in
  // the data-load cell below).
  const dateWrap = document.createElement("div")
  dateWrap.className = "filter-round-wrap"
  const dLbl = document.createElement("span")
  dLbl.className = "filter-label"
  dLbl.textContent = "Dates"
  const dMin = document.createElement("input")
  dMin.type = "date"; dMin.className = "round-input"; dMin.style.minWidth = "8.5rem"
  const dSep = document.createElement("span")
  dSep.className = "round-sep"; dSep.textContent = "–"
  const dMax = document.createElement("input")
  dMax.type = "date"; dMax.className = "round-input"; dMax.style.minWidth = "8.5rem"
  dateWrap.appendChild(dLbl); dateWrap.appendChild(dMin); dateWrap.appendChild(dSep); dateWrap.appendChild(dMax)

  // Home/Away toggle
  const haWrap = document.createElement("div")
  haWrap.className = "filter-agg-wrap"
  for (const label of ["All", "Home", "Away"]) {
    const btn = document.createElement("button")
    btn.className = "agg-btn" + (label === "All" ? " active" : "")
    btn.textContent = label
    btn.dataset.ha = label
    btn.addEventListener("click", () => {
      haWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = { ...container.value, homeAway: label }
      emit()
    })
    haWrap.appendChild(btn)
  }

  row.appendChild(league.wrap)
  row.appendChild(season.wrap)
  row.appendChild(matchday.wrap)
  row.appendChild(daySelect.wrap)
  row.appendChild(dateWrap)
  row.appendChild(haWrap)

  // Agg toggle: Per Game / Total only (no Per 90 for teams)
  const aggWrap = document.createElement("div")
  aggWrap.className = "filter-agg-wrap"
  const btnAvg = document.createElement("button")
  btnAvg.className = "agg-btn active"
  btnAvg.textContent = "Per Game"
  btnAvg.dataset.mode = "avg"
  const btnTot = document.createElement("button")
  btnTot.className = "agg-btn"
  btnTot.textContent = "Total"
  btnTot.dataset.mode = "total"
  aggWrap.appendChild(btnAvg)
  aggWrap.appendChild(btnTot)
  row.appendChild(aggWrap)

  container.appendChild(row)

  // ── State and dispatch ──
  container.value = { league: "ENG", season: "All Seasons", matchday: "All Matchdays", day: "All Days", dateMin: "", dateMax: "", homeAway: "All", aggMode: "avg" }

  function emit() {
    container.dispatchEvent(new Event("input", { bubbles: true }))
  }

  league.sel.addEventListener("change", () => {
    container.value = { ...container.value, league: league.sel.value }
    emit()
  })
  season.sel.addEventListener("change", () => {
    container.value = { ...container.value, season: season.sel.value, matchday: "All Matchdays" }
    emit()
  })
  matchday.sel.addEventListener("change", () => {
    container.value = { ...container.value, matchday: matchday.sel.value }
    emit()
  })
  daySelect.sel.addEventListener("change", () => {
    container.value = { ...container.value, day: daySelect.sel.value }
    emit()
  })

  let dateTimer
  function updateDate() {
    clearTimeout(dateTimer)
    dateTimer = setTimeout(() => {
      container.value = { ...container.value, dateMin: dMin.value || "", dateMax: dMax.value || "" }
      emit()
    }, 300)
  }
  dMin.addEventListener("change", updateDate)
  dMax.addEventListener("change", updateDate)

  for (const btn of [btnAvg, btnTot]) {
    btn.addEventListener("click", () => {
      aggWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = { ...container.value, aggMode: btn.dataset.mode }
      emit()
    })
  }

  // Expose method to update season options externally
  container._updateSeasons = (options, defaultVal) => {
    const oldVal = container.value.season
    while (season.sel.firstChild) season.sel.removeChild(season.sel.firstChild)
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt
      o.textContent = opt
      if (opt === defaultVal) o.selected = true
      season.sel.appendChild(o)
    }
    if (defaultVal !== oldVal) {
      container.value = { ...container.value, season: defaultVal, matchday: "All Matchdays" }
      container.dispatchEvent(new Event("input", { bubbles: true }))
    }
  }

  // Expose method to update matchday options externally
  container._updateMatchdays = (options) => {
    while (matchday.sel.firstChild) matchday.sel.removeChild(matchday.sel.firstChild)
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt
      o.textContent = opt
      if (opt === "All Matchdays") o.selected = true
      matchday.sel.appendChild(o)
    }
    container.value = { ...container.value, matchday: "All Matchdays" }
  }

  // Expose method to set the date inputs to a season's played-game window.
  // Called from the matchStats data-load cell whenever season changes.
  container._updateDates = (min, max) => {
    dMin.value = min || ""
    dMax.value = max || ""
    container.value = { ...container.value, dateMin: dMin.value, dateMax: dMax.value }
    container.dispatchEvent(new Event("input", { bubbles: true }))
  }

  return container
}

// ── Destructure for downstream reactivity ────────────────────
leagueFilter = filters.league
seasonFilter = filters.season
matchdayFilter = filters.matchday
dayFilter = filters.day
dateRange = ({ min: filters.dateMin, max: filters.dateMax })
homeAwayFilter = filters.homeAway
aggMode = filters.aggMode
Show code
fixtureData = {
  try {
    const res = await fetch(window.DATA_BASE_URL + "football/fixtures.json")
    if (res.ok) { const data = await res.json(); return data.matches || null }
  } catch (e) { console.warn("[team-stats] R2 fixture fetch failed, falling back to Worker:", e.message) }
  const data = await window.fetchFixtures("football")
  return data ? (data.matches || null) : null
}

// ── Load match-stats for selected league(s) ──────────────────
// Two-tier result: rows + list of failed league codes. `matchStats` aliases
// the rows for legacy callers; the banner cell below reads
// `_matchStatsResult.failed` to surface partial-fetch failures.
_matchStatsResult = {
  const codes = leagueFilter === "All Leagues" ? leagueCodes : [leagueFilter]
  let results = []
  const failed = []
  for (const code of codes) {
    try {
      const data = await window.fetchParquet(base + `football/match-stats-${code}.parquet`)
      // .concat() avoids the spread argument-count limit (~32K) on big league files.
      if (data) results = results.concat(data); else failed.push(code)
    } catch (e) {
      console.error(`[team-stats] match-stats-${code} load failed:`, e)
      failed.push(code)
    }
  }
  return { rows: results.length > 0 ? results : null, failed, requestedCodes: codes }
}
matchStats = _matchStatsResult.rows

seasonOptions = {
  if (!matchStats) return ["All Seasons"]
  const seasons = [...new Set(matchStats.map(d => String(d.season)))].sort().reverse()
  return ["All Seasons", ...seasons]
}

// Update season dropdown when options change
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateSeasons) {
    const defaultSeason = seasonOptions[1] || "All Seasons"
    el._updateSeasons(seasonOptions, defaultSeason)
  }
}
Show code
matchdayOptions = {
  if (!matchStats) return ["All Matchdays"]
  let games = matchStats
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)
  const dates = [...new Set(games.map(d => {
    const md = d.match_date
    return md ? String(md).replace("Z", "").slice(0, 10) : null
  }).filter(Boolean))].sort()
  return ["All Matchdays", ...dates]
}

// Update matchday dropdown when season changes
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateMatchdays) {
    el._updateMatchdays(matchdayOptions)
  }
}
Show code
{
  const el = document.querySelector(".player-filter-bar")
  if (!el || !el._updateDates) return
  const _key = "_lastDateSeason_team-stats"
  const currentKey = `${seasonFilter}|${matchStats ? matchStats.length : 0}`
  if (window[_key] === currentKey) return
  window[_key] = currentKey
  if (!matchStats || matchStats.length === 0) { el._updateDates("", ""); return }
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  const games = effectiveSeason ? matchStats.filter(d => String(d.season) === effectiveSeason) : matchStats
  const dates = games.map(d => String(d.match_date || "").replace("Z", "").slice(0, 10)).filter(Boolean).sort()
  if (dates.length === 0) { el._updateDates("", ""); return }
  el._updateDates(dates[0], dates[dates.length - 1])
}
Show code
asAtLabel = {
  if (!matchStats || matchStats.length === 0) return ""
  const dates = matchStats.map(d => d.match_date).filter(Boolean).sort()
  if (dates.length === 0) return ""
  // Strip the trailing "Z" the parquet ships and format as "22 Apr 2026"
  // to match football/player-ratings.qmd convention.
  const latest = dates[dates.length - 1]
  const dt = new Date(String(latest).replace("Z", "") + "T12:00:00Z")
  const formatted = isNaN(dt.getTime())
    ? latest
    : dt.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
  const leagueName = leagueFilter === "All Leagues" ? "All Leagues" : (leagueNames[leagueFilter] || leagueFilter)
  return `${leagueName} · As at ${formatted}`
}

html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${statsEsc(asAtLabel)}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="team-ratings.html">Team Ratings &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
catDef = statDefs[category]

// Collect ALL stat columns across all categories (for custom tab support)
allStatCols = {
  const seen = new Set()
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom" || key === "results") continue
    for (const col of def.columns) {
      if (!col.endsWith("_pct")) seen.add(col)
    }
  }
  return [...seen]
}

teamData = {
  if (!matchStats) return null

  let games = matchStats
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter

  // Season filter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)

  // Matchday filter
  if (matchdayFilter && matchdayFilter !== "All Matchdays") {
    games = games.filter(d => {
      const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
      return md === matchdayFilter
    })
  }

  // Day-of-week filter
  if (dayFilter && dayFilter !== "All Days") {
    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    games = games.filter(d => {
      if (!d.match_date) return false
      const md = String(d.match_date).replace("Z", "").slice(0, 10)
      return dayNames[new Date(md + "T12:00:00Z").getUTCDay()] === dayFilter
    })
  }

  // Date range filter — match-stats rows carry match_date directly, no
  // fixture lookup needed. Empty bound = no limit on that side.
  if (dateRange.min || dateRange.max) {
    games = games.filter(d => {
      if (!d.match_date) return false
      const md = String(d.match_date).replace("Z", "").slice(0, 10)
      if (dateRange.min && md < dateRange.min) return false
      if (dateRange.max && md > dateRange.max) return false
      return true
    })
  }

  // Home/Away filter — build fixture lookup for H/A determination
  const homeAwayMode = homeAwayFilter || "All"
  const normalize = s => (s || "").replace(/\b(fc|afc|sfc|sc|cf)\b/gi, "").replace(/\s+/g, " ").trim().toLowerCase()
  const fixtureHAMap = new Map() // "matchdate|teamnorm" → "home"|"away"
  if (fixtureData && homeAwayMode !== "All") {
    for (const f of fixtureData) {
      if (f.status !== "FINISHED" || !f.date) continue
      const dateKey = f.date.slice(0, 10)
      fixtureHAMap.set(`${dateKey}|${normalize(f.homeTeam)}`, "home")
      fixtureHAMap.set(`${dateKey}|${normalize(f.awayTeam)}`, "away")
    }
  }

  // Group by team — sum player stats per match, then aggregate across matches
  // Pass 1: build per-match per-team totals
  const matchTeamMap = new Map()
  for (const g of games) {
    const team = g.team_name
    if (!team) continue
    const matchKey = g.match_id || `${g.match_date}|${g.opponent || ""}`
    const key = `${team}|||${matchKey}`
    if (!matchTeamMap.has(key)) {
      matchTeamMap.set(key, { team, matchKey, opponent: g.opponent, vals: {} })
      for (const col of allStatCols) matchTeamMap.get(key).vals[col] = 0
    }
    const entry = matchTeamMap.get(key)
    for (const col of allStatCols) {
      const v = Number(g[col])
      if (!isNaN(v)) entry.vals[col] += v
    }
  }

  // Build matchKey-based lookup for opponent cross-reference BEFORE H/A filter
  // so goals-against lookups still work when one side is filtered out
  const matchLookup = new Map()  // matchKey -> Map(team -> entry)
  for (const [, entry] of matchTeamMap) {
    if (!matchLookup.has(entry.matchKey)) matchLookup.set(entry.matchKey, new Map())
    matchLookup.get(entry.matchKey).set(entry.team, entry)
  }

  // Apply Home/Away filter: remove match-team entries where H/A doesn't match
  if (homeAwayMode !== "All") {
    for (const [key, entry] of matchTeamMap) {
      const matchDate = games.find(g => (g.match_id || `${g.match_date}|${g.opponent || ""}`) === entry.matchKey)?.match_date
      const dateKey = matchDate ? String(matchDate).replace("Z", "").slice(0, 10) : ""
      const ha = fixtureHAMap.get(`${dateKey}|${normalize(entry.team)}`)
      if (ha && ((homeAwayMode === "Home" && ha !== "home") || (homeAwayMode === "Away" && ha !== "away"))) {
        matchTeamMap.delete(key)
      }
    }
  }

  // Pass 2: aggregate match totals into team totals with results cross-lookup
  const teamMap = new Map()
  for (const [, mEntry] of matchTeamMap) {
    const team = mEntry.team
    if (!teamMap.has(team)) {
      teamMap.set(team, { matchKeys: new Set(), vals: {}, w: 0, l: 0, d: 0, gf: 0, ga: 0 })
      for (const col of allStatCols) teamMap.get(team).vals[col] = 0
    }
    const entry = teamMap.get(team)
    entry.matchKeys.add(mEntry.matchKey)
    for (const col of allStatCols) {
      entry.vals[col] += mEntry.vals[col]
    }

    // Cross-lookup: find the OTHER team in the same match (avoids name matching)
    const teamGoals = mEntry.vals.goals || 0
    const mLookup = matchLookup.get(mEntry.matchKey)
    let oppEntry = null
    if (mLookup) {
      for (const [t, e] of mLookup) {
        if (t !== mEntry.team) { oppEntry = e; break }
      }
    }
    if (!oppEntry) console.warn(`[team-stats] No opponent data for match ${mEntry.matchKey} (${mEntry.team})`)
    const oppGoals = oppEntry ? (oppEntry.vals.goals || 0) : 0

    entry.gf += teamGoals
    entry.ga += oppGoals
    if (teamGoals > oppGoals) entry.w++
    else if (teamGoals < oppGoals) entry.l++
    else entry.d++
  }

  // Pass 3: build output rows
  const isAvg = aggMode === "avg"
  const result = []
  for (const [team, entry] of teamMap) {
    const gp = entry.matchKeys.size
    if (gp === 0) continue
    const row = { team_name: team, gp }
    for (const col of allStatCols) {
      row[col] = isAvg ? +(entry.vals[col] / gp).toFixed(2) : entry.vals[col]
    }
    // Computed % columns — derived from aggregated totals so the rate is
    // correct regardless of Per Game / Total. Null when denominator is 0.
    row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
    row.tackles_won_pct = row.tackles > 0 ? Math.round(100 * row.tackles_won / row.tackles) : null
    const _aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
    row.aerials_won_pct = _aerTot > 0 ? Math.round(100 * row.aerials_won / _aerTot) : null
    const _duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
    row.duels_won_pct = _duelTot > 0 ? Math.round(100 * row.duels_won / _duelTot) : null
    row.shots_on_target_pct = row.shots > 0 ? Math.round(100 * row.shots_on_target / row.shots) : null

    // Results columns — W/L/D always totals
    row.w = entry.w
    row.l = entry.l
    row.d = entry.d
    row.win_pct = gp > 0 ? +((entry.w / gp) * 100).toFixed(1) : 0
    row.gf = isAvg ? +(entry.gf / gp).toFixed(2) : entry.gf
    row.ga = isAvg ? +(entry.ga / gp).toFixed(2) : entry.ga
    row.gd = isAvg ? +((entry.gf - entry.ga) / gp).toFixed(2) : entry.gf - entry.ga

    // Join xG from league-xg.parquet
    if (_leagueXg) {
      const xg = _leagueXg.find(x => x.team_name === team && (leagueFilter === "All Leagues" || x.league === leagueFilter))
      if (!xg) console.debug("[team-stats] No xG match for:", team)
      if (xg) {
        row.xgf = isAvg && gp > 0 ? +(xg.xgf / gp).toFixed(2) : xg.xgf != null ? +xg.xgf.toFixed(1) : null
        row.xga = isAvg && gp > 0 ? +(xg.xga / gp).toFixed(2) : xg.xga != null ? +xg.xga.toFixed(1) : null
        row.xgd = isAvg && gp > 0 ? +((xg.xgf - xg.xga) / gp).toFixed(2) : xg.xgd != null ? +xg.xgd.toFixed(1) : null
      }
    }
    result.push(row)
  }

  return result
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
  if (!window._teamStatsView) window._teamStatsView = "Table"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const label of ["Table", "Scatter"]) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (label === window._teamStatsView ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      window._teamStatsView = label
      const isTable = label === "Table"
      const tableView = document.querySelector(".team-stats-table-view")
      const scatterView = document.querySelector(".team-stats-scatter-view")
      if (tableView) tableView.style.display = isTable ? "" : "none"
      if (scatterView) scatterView.style.display = isTable ? "none" : ""
    })
    container.appendChild(btn)
  }
  return container
}
Show code
// Partial-failure banner — surfaces league fetches that 404'd / errored.
{
  const failed = _matchStatsResult && _matchStatsResult.failed
  if (!failed || failed.length === 0) return html``
  const leagueNames = window.footballMaps.leagueNames || {}
  const labels = failed.map(c => leagueNames[c] || c).join(", ")
  const isAllFailed = !_matchStatsResult.rows
  return html`<div class="data-load-warning" role="status">
    <span><strong>${isAllFailed ? "Couldn't load data:" : "Some data didn't load:"}</strong> ${labels}.${isAllFailed ? "" : " Showing partial results."}</span>
    <button class="data-load-warning-btn" onclick="window.location.reload()">Retry</button>
  </div>`
}
Show code
activeData = category === "custom" ? (customCols?.length > 0 ? teamData : null) : teamData

// Always return a real Inputs.search element — see football/player-stats.qmd for rationale.
viewof search = Inputs.search(activeData || [], { placeholder: "Search teams\u2026" })
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  const data = activeData
  const def = category === "custom" ? effectiveCatDef : catDef

  if (!data || data.length === 0 || !def) return html``

  const statCols = def.columns.filter(c => data[0] && data[0][c] !== undefined)
  const metricOpts = statCols.map(c => ({ value: c, label: def.header[c] || c }))
  if (metricOpts.length < 1) return html``

  const defaultX = metricOpts[0]?.value
  const defaultY = metricOpts[1]?.value || metricOpts[0]?.value

  const headerSrc = Object.fromEntries(metricOpts.map(m => [m.value, m.label]))

  const wrapper = document.createElement("div")
  wrapper.className = "team-stats-scatter-view"
  wrapper.style.display = window._teamStatsView === "Scatter" ? "" : "none"

  const axisBar = document.createElement("div")
  axisBar.className = "scatter-axis-bar"

  const xLabel = document.createElement("label")
  xLabel.textContent = "X: "
  const xSel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; xSel.appendChild(o)
  }
  xSel.value = defaultX
  xLabel.appendChild(xSel)

  const yLabel = document.createElement("label")
  yLabel.textContent = "Y: "
  const ySel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; ySel.appendChild(o)
  }
  ySel.value = defaultY
  yLabel.appendChild(ySel)

  axisBar.appendChild(xLabel)
  axisBar.appendChild(yLabel)
  wrapper.appendChild(axisBar)

  const chartDiv = document.createElement("div")
  wrapper.appendChild(chartDiv)

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "team_name",
      format: { [xCol]: v => Number(v).toFixed(2), [yCol]: v => Number(v).toFixed(2) },
      hrefFn: (row) => `team.html#team=${encodeURIComponent(row.team_name)}`,
      imageFn: (row) => window.footballMaps?.teamCrest(row.team_name) || null,
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const crest = window.footballMaps.teamCrest(row.team_name)
        if (crest) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = crest
          badge.alt = ""
          badge.style.borderRadius = "4px"
          header.appendChild(badge)
        }
        const info = document.createElement("div")
        const nameEl = document.createElement("div")
        nameEl.className = "scatter-tip-name"
        nameEl.textContent = row.team_name || ""
        info.appendChild(nameEl)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(2)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(2)
        window.chartHelpers.buildFieldTooltip(tip, "", [[xL, fX], [yL, fY]], true)
        const title = tip.querySelector(".ft-title")
        if (title && !title.textContent) title.remove()
      }
    })
  }

  drawChart(defaultX, defaultY)

  xSel.addEventListener("change", () => drawChart(xSel.value, ySel.value))
  ySel.addEventListener("change", () => drawChart(xSel.value, ySel.value))

  return wrapper
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const data = activeData
  const def = category === "custom" ? effectiveCatDef : catDef

  if (category === "custom" && (!customCols || customCols.length === 0)) {
    return html`<p class="text-muted">Select up to 10 columns above to build your custom table.</p>`
  }

  if (!data || data.length === 0) {
    return html`<p class="text-muted">No data available. Select a league and season to view team stats.</p>`
  }

  const statCols = def.columns.filter(c => {
    return data[0] && data[0][c] !== undefined
  })

  const columns = ["team_name", "gp", ...statCols]

  const header = {
    team_name: "Team",
    gp: "GP",
    ...def.header,
  }

  const groups = [
    { label: "Team", span: 2 },
    { label: def.label, span: statCols.length },
  ]

  const _pctFmt = x => x != null ? x + "%" : ""
  const format = {
    pass_pct: _pctFmt,
    tackles_won_pct: _pctFmt,
    aerials_won_pct: _pctFmt,
    duels_won_pct: _pctFmt,
    shots_on_target_pct: _pctFmt,
    win_pct: x => x != null ? x.toFixed(1) + "%" : ""
  }

  const mCols = def.mobileCols ? ["team_name", "gp", ...def.mobileCols] : null

  const tooltip = { ...def.tooltip, gp: "Games played" }

  const tableEl = statsTable(search, {
    columns,
    mobileCols: mCols,
    header,
    groups,
    format,
    tooltip,
    render: {
      team_name: window.footballMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
    },
    heatmap: def.heatmap || {},
    heatmapData: data,
    filters: {
      ...(def.sortCol ? { [def.sortCol]: "range" } : {}),
      gp: "range"
    },
    sort: def.sortCol,
    reverse: true,
    rows: 25
  })

  const wrap = document.createElement("div")
  wrap.className = "team-stats-table-view"
  wrap.style.display = window._teamStatsView === "Table" ? "" : "none"
  wrap.appendChild(tableEl)
  return wrap
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/pannadata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "pannadata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Opta scrape · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = (asAtLabel || "Latest") + " · Filter by league + matchday + team"
  src.appendChild(left); src.appendChild(right)
  return src
}
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("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = asAtLabel || "Latest"
  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("Team stats refresh after every matchday via the daily "))
  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."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN intentionally omitted on this page — the data shape varies by selected
  // category (Results, Goals, Defensive, etc.) so a single set of meaningful
  // numbers doesn't generalise. The About + Read Next blocks below carry the
  // editorial weight instead.

  // About
  const about = railBlock("Stats vs Ratings"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("These are "))
  const s1 = document.createElement("strong"); s1.textContent = "raw aggregations"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" — totals or averages of what teams actually did, with no opponent-strength adjustment."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("For opponent-adjusted "))
  const s2 = document.createElement("strong"); s2.textContent = "ratings"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(", see "))
  const a2 = document.createElement("a")
  a2.href = "team-ratings.html"; a2.textContent = "Team Ratings"
  p2.appendChild(a2); p2.appendChild(document.createTextNode("."))
  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: "team-ratings.html", title: "Team Ratings", meta: "Opponent-adjusted strength" },
    { href: "team-game-logs.html", title: "Team Game Logs", meta: "Per-match team box scores" },
    { href: "leagues.html", title: "Leagues & Sims", meta: "Projected final standings" },
    { href: "player-stats.html", title: "Player Stats", meta: "Per-player box scores" }
  ]
  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