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 Game Logs

Skip to content

Football > Team Game Logs

Football · Team Game Logs · One Row Per Team Per Match

Which team performances are worth remembering?

Every team, every fixture across 15 leagues — sortable. Use this when you want to find the season’s biggest xG-overperformance, the highest possession share that still lost, or the most pressing intensity any club has produced.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every matchday</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

leagueCodes = window.footballMaps.clubLeagues
leagueNames = window.footballMaps.leagueNames

statDefs = window.footballStatDefs || {}

// Categories: exclude ratings and custom
catKeys = Object.keys(statDefs).filter(k => !statDefs[k].page && k !== "custom")
Show code
// ── Category toggle ──────────────────────────────────────────
viewof category = {
  const _key = "_teamGameLogCat_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "scoring"
  const _default = catKeys.includes(_saved) ? _saved : "scoring"
  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
viewof filters = {
  function makeSelect(options, defaultVal, label) {
    const wrap = document.createElement("div")
    wrap.className = "filter-select-wrap"
    const lbl = document.createElement("span")
    lbl.className = "filter-label"
    lbl.textContent = label
    const sel = document.createElement("select")
    sel.className = "filter-select"
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      if (opt === defaultVal) o.selected = true
      sel.appendChild(o)
    }
    wrap.appendChild(lbl); wrap.appendChild(sel)
    return { wrap, sel }
  }

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

  const leagueOpts = ["All Leagues", ...leagueCodes.map(c => `${leagueNames[c] || c} (${c})`)]
  const league = makeSelect(leagueOpts, leagueOpts.find(l => l.includes("ENG")) || leagueOpts[0], "League")

  const season = makeSelect(["All Seasons"], "All Seasons", "Season")

  row.appendChild(league.wrap)
  row.appendChild(season.wrap)
  container.appendChild(row)

  container.value = { league: "ENG", season: "All Seasons" }

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

  league.sel.addEventListener("change", () => {
    const raw = league.sel.value
    const code = raw === "All Leagues" ? "All Leagues" : raw.match(/\((\w+)\)$/)?.[1] || raw
    container.value = { ...container.value, league: code, season: "All Seasons" }
    emit()
  })
  season.sel.addEventListener("change", () => {
    container.value = { ...container.value, season: season.sel.value }
    emit()
  })

  container._updateSeasons = function (opts, defaultVal) {
    const oldVal = container.value.season
    while (season.sel.firstChild) season.sel.removeChild(season.sel.firstChild)
    for (const opt of opts) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      if (opt === defaultVal) o.selected = true
      season.sel.appendChild(o)
    }
    if (oldVal !== defaultVal) {
      container.value = { ...container.value, season: defaultVal }
      emit()
    }
  }

  return container
}

leagueFilter = filters.league
seasonFilter = filters.season
Show code
_matchStatsResult = {
  const codes = leagueFilter === "All Leagues" ? leagueCodes : [leagueFilter]
  const failed = []
  // Fetch all per-league files concurrently (browser caps ~6/origin) instead of
  // serially — on "All Leagues" this turns up to ~15 sequential awaits into one
  // parallel batch. Per-league error accounting (failed.push) and the input
  // order are preserved by mapping in `codes` order and concatenating in order.
  const perLeague = await Promise.all(codes.map(async (code) => {
    try {
      const data = await window.fetchParquet(base + `football/match-stats-${code}.parquet`)
      if (data) return data
      failed.push(code)
      return null
    } catch (e) {
      console.error(`[team-game-logs] match-stats-${code} load failed:`, e)
      failed.push(code)
      return null
    }
  }))
  let results = []
  // .concat() avoids the spread argument-count limit (~32K) on big league files.
  for (const data of perLeague) { if (data) results = results.concat(data) }
  return { rows: results.length > 0 ? results : null, failed, requestedCodes: codes }
}
matchStats = _matchStatsResult.rows

// Load game-logs for Value tab
_gameLogs = {
  try { return await window.fetchParquet(base + "football/game-logs.parquet") } catch (e) { console.warn("[team-game-logs] game-logs load failed:", e); return null }
}

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

// Update season dropdown
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateSeasons) {
    const defaultSeason = seasonOptions[1] || "All Seasons"
    el._updateSeasons(seasonOptions, defaultSeason)
  }
}
Show code
// ── Build team game log table data ──────────────────────────
tableData = {
  const catDef = statDefs[category]
  if (!catDef) return null

  const source = catDef.source
  let rawData
  if (source === "gameLogs") {
    rawData = _gameLogs
  } else {
    rawData = matchStats
  }
  if (!rawData) return null

  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter

  let games = rawData

  // League filter (for game-logs which may span leagues)
  if (leagueFilter !== "All Leagues" && source === "gameLogs") {
    games = games.filter(d => d.league === leagueFilter)
  }

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

  // Aggregate player rows into team-game rows. Strip computed % cols so they
  // aren't summed (rate-derived from base counts in the compute block below).
  const statCols = catDef.columns.filter(c => !c.endsWith("_pct"))
  const teamGameMap = new Map()

  for (const row of games) {
    const team = row.team_name || ""
    if (!team) continue
    const date = row.match_date ? String(row.match_date).replace("Z", "").slice(0, 10) : ""
    const opp = row.opponent || ""
    const key = `${team}|||${date}|||${opp}`

    if (!teamGameMap.has(key)) {
      teamGameMap.set(key, { team, date, opponent: opp, season: row.season, vals: {} })
      for (const c of statCols) teamGameMap.get(key).vals[c] = 0
    }
    const entry = teamGameMap.get(key)
    for (const c of statCols) {
      entry.vals[c] = (entry.vals[c] || 0) + (Number(row[c]) || 0)
    }
  }

  // Build output rows
  return [...teamGameMap.values()].map(e => {
    const row = {
      team: e.team,
      date: e.date,
      opponent: e.opponent
    }
    for (const col of statCols) {
      const v = e.vals[col]
      row[col] = isNaN(v) ? null : +(v.toFixed(1))
    }
    // Recompute % cols from summed totals. Null when denominator is 0 so the
    // cell is blank rather than misleadingly 0%.
    if (catDef.compute) {
      const cols = catDef.columns
      if (cols.includes("pass_pct"))
        row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
      if (cols.includes("tackles_won_pct"))
        row.tackles_won_pct = row.tackles > 0 ? Math.round(100 * row.tackles_won / row.tackles) : null
      if (cols.includes("aerials_won_pct")) {
        const aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
        row.aerials_won_pct = aerTot > 0 ? Math.round(100 * row.aerials_won / aerTot) : null
      }
      if (cols.includes("duels_won_pct")) {
        const duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
        row.duels_won_pct = duelTot > 0 ? Math.round(100 * row.duels_won / duelTot) : null
      }
      if (cols.includes("shots_on_target_pct"))
        row.shots_on_target_pct = row.shots > 0 ? Math.round(100 * row.shots_on_target / row.shots) : null
    }
    return row
  })
}
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
viewof search = tableData == null
  ? html``
  : Inputs.search(tableData, { placeholder: "Search teams..." })
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (tableData == null || tableData.length === 0)
    return html`<p class="text-muted">No data available for this category and filter combination.</p>`

  const catDef = statDefs[category]
  const statCols = catDef.columns

  const columns = ["team", "date", "opponent", ...statCols]

  const header = {
    team: "Team", date: "Date", opponent: "Vs",
    ...catDef.header
  }

  const format = {}
  for (const col of statCols) {
    format[col] = col.endsWith("_pct")
      ? (v => v != null ? Math.round(Number(v)) + "%" : "")
      : (v => v != null ? String(Math.round(Number(v))) : "")
  }

  const heatmap = {}
  for (const col of statCols) {
    if (catDef.heatmap?.[col]) heatmap[col] = catDef.heatmap[col]
  }

  const render = {}
  const renderTeamCell = window.footballMaps?.renderTeamCell
  if (renderTeamCell) {
    render.team = (val) => renderTeamCell(val)
    render.opponent = (val) => renderTeamCell(val)
  }

  const groups = [
    { label: "", span: 3 },
    { label: catDef.label, span: statCols.length }
  ]

  const sortCol = catDef.sortCol || statCols[0]

  return statsTable(search, {
    columns, header, groups, format, heatmap, render,
    sort: sortCol, reverse: true,
    pageSize: 25
  })
}
Show code
tglAsAt = window.editorial.dataUpdated(base + "football/game-logs.parquet")
Show code
// ── Source attribution row ──────────────────────────────────
{
  const asAt = await tglAsAt
  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 = [asAt, "One row per (team, match)"].filter(Boolean).join(" · ")
  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 } = window.editorial

  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "After every matchday"
  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 box scores land in R2 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, ~24h after each fixture."))
  upd.appendChild(updP); inner.appendChild(upd)

  const how = railBlock("How to read this"); how.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each row is "))
  const s1 = document.createElement("strong"); s1.textContent = "one team in one match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(". Use this to find single-game extremes — biggest xG, highest possession, most pressing actions."))
  how.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Switch to "))
  const a = document.createElement("a"); a.href = "team-stats.html"; a.textContent = "Team Stats"
  p2.appendChild(a)
  p2.appendChild(document.createTextNode(" for season-long aggregates."))
  how.appendChild(p2)
  inner.appendChild(how)

  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "team-stats.html", title: "Team Stats", meta: "Season aggregates" },
    { href: "team-ratings.html", title: "Team Ratings", meta: "Opponent-adjusted strength" },
    { href: "game-logs.html", title: "Player Game Logs", meta: "Player-level single games" },
    { href: "leagues.html", title: "Leagues & Sims", meta: "Projected final standings" }
  ]
  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