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

Skip to content
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
Show code
gameStats = {
  try { return await fetchParquet(base_url + "afl/game-stats.parquet") }
  catch (e) { console.error("[team-stats] game-stats load failed:", e); return null }
}
predictions = {
  try { return await fetchParquet(base_url + "afl/predictions.parquet") }
  catch (e) { console.error("[team-stats] predictions load failed:", e); return null }
}
fixturesHistory = {
  try {
    return await window.fetchParquet(base_url + "afl/fixtures-history.parquet")
  } catch (e) {
    console.warn("[team-stats] fixtures-history load failed:", e)
    return null
  }
}
Show code
predToFull = window.aflTeamMaps?.predToFull || {}

AFL > Team Stats

AFL · Team Stats · Aggregated Box Score

Which teams are really pulling their weight?

Match-by-match team box scores rolled up to season aggregates: total disposals, inside-50s, marks, tackles, contested possessions — sliced by season, round, or opponent so the story isn’t drowned in season-long totals.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${teamAsAtLabel ? statsEsc(teamAsAtLabel) : "After every round"}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">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
viewof teamStatsFilters = {
  if (!gameStats) { const e = document.createElement("div"); e.value = { season: null, roundMin: null, roundMax: null, aggMode: "total" }; return e }

  const seasons = [...new Set(gameStats.map(d => Number(d.season)))].sort((a, b) => b - a)
  const defaultSeason = seasons[0]
  // 0 is the "All Seasons" sentinel — it can never collide with a real season
  // (parquet is 2010+) and stays numeric so existing `Number(d.season) === s`
  // comparisons keep working unchanged.
  const ALL_SEASONS = 0

  function getRoundRange(season) {
    const rounds = season === ALL_SEASONS
      ? gameStats.map(d => d.round).filter(r => r != null)
      : gameStats.filter(d => Number(d.season) === season).map(d => d.round).filter(r => r != null)
    return { min: rounds.length > 0 ? Math.min(...rounds) : 0, max: rounds.length > 0 ? Math.max(...rounds) : 30 }
  }

  // Date range = played-game window for the given season (or full played
  // history for All Seasons). fixturesHistory is the SSOT for game dates;
  // gameStats rows don't carry start_time directly so we derive bounds here.
  function getDateRange(season) {
    if (!fixturesHistory) return { min: "", max: "" }
    const candidates = season === ALL_SEASONS
      ? fixturesHistory
      : fixturesHistory.filter(f => Number(f.season) === season)
    const now = new Date().toISOString()
    const played = candidates
      .map(f => f.start_time)
      .filter(t => t && t <= now)
      .map(t => String(t).slice(0, 10))
      .sort()
    if (played.length === 0) return { min: "", max: "" }
    return { min: played[0], max: played[played.length - 1] }
  }

  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 (String(opt) === String(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 season = makeSelect([...seasons, ALL_SEASONS], defaultSeason, "Season")
  // Display the sentinel as a friendly label without changing the underlying value
  const allSeasonsOpt = [...season.sel.options].find(o => o.value === String(ALL_SEASONS))
  if (allSeasonsOpt) allSeasonsOpt.textContent = "All Seasons"
  const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
  const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
  const venueOpts = fixturesHistory
    ? ["All Venues", ...[...new Set(fixturesHistory.map(f => f.venue).filter(Boolean))].sort()]
    : ["All Venues"]
  const venueSelect = makeSelect(venueOpts, "All Venues", "Venue")
  const dayOpts = ["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
  const daySelect = makeSelect(dayOpts, "All Days", "Day")
  row.appendChild(season.wrap)
  row.appendChild(haSelect.wrap)
  row.appendChild(seasonType.wrap)
  row.appendChild(venueSelect.wrap)
  row.appendChild(daySelect.wrap)

  // Round range
  let roundBounds = getRoundRange(defaultSeason)
  const roundWrap = document.createElement("div")
  roundWrap.className = "filter-round-wrap"
  const rLbl = document.createElement("span")
  rLbl.className = "filter-label"
  rLbl.textContent = "Rounds"
  const rMin = document.createElement("input")
  rMin.type = "number"; rMin.className = "round-input"
  rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
  const rSep = document.createElement("span")
  rSep.className = "round-sep"; rSep.textContent = "–"
  const rMax = document.createElement("input")
  rMax.type = "number"; rMax.className = "round-input"
  rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
  roundWrap.appendChild(rLbl)
  roundWrap.appendChild(rMin)
  roundWrap.appendChild(rSep)
  roundWrap.appendChild(rMax)
  row.appendChild(roundWrap)

  // Date range — defaults to the played-game window of the selected season.
  // Resets reactively when the season changes (see season.sel handler below).
  let dateBounds = getDateRange(defaultSeason)
  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"
  dMin.value = dateBounds.min
  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"
  dMax.value = dateBounds.max
  dateWrap.appendChild(dLbl)
  dateWrap.appendChild(dMin)
  dateWrap.appendChild(dSep)
  dateWrap.appendChild(dMax)
  row.appendChild(dateWrap)

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

  container.appendChild(row)

  container.value = {
    season: defaultSeason,
    roundMin: roundBounds.min,
    roundMax: roundBounds.max,
    dateMin: dateBounds.min,
    dateMax: dateBounds.max,
    aggMode: "total",
    homeAway: "All",
    seasonType: "All",
    venue: "All Venues",
    day: "All Days"
  }

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

  season.sel.addEventListener("change", () => {
    const s = Number(season.sel.value)
    roundBounds = getRoundRange(s)
    rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
    rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
    // Reset date bounds to the new season's played-game window
    dateBounds = getDateRange(s)
    dMin.value = dateBounds.min
    dMax.value = dateBounds.max
    container.value = {
      ...container.value,
      season: s,
      roundMin: roundBounds.min, roundMax: roundBounds.max,
      dateMin: dateBounds.min, dateMax: dateBounds.max
    }
    emit()
  })

  haSelect.sel.addEventListener("change", () => {
    container.value = { ...container.value, homeAway: haSelect.sel.value }
    emit()
  })
  seasonType.sel.addEventListener("change", () => {
    container.value = { ...container.value, seasonType: seasonType.sel.value }
    emit()
  })
  venueSelect.sel.addEventListener("change", () => {
    container.value = { ...container.value, venue: venueSelect.sel.value }
    emit()
  })
  daySelect.sel.addEventListener("change", () => {
    container.value = { ...container.value, day: daySelect.sel.value }
    emit()
  })

  function clampRound(input) {
    if (input.value === "") return
    const v = +input.value
    if (v < roundBounds.min) input.value = roundBounds.min
    if (v > roundBounds.max) input.value = roundBounds.max
  }
  let roundTimer
  function updateRound() {
    clearTimeout(roundTimer)
    roundTimer = setTimeout(() => {
      clampRound(rMin); clampRound(rMax)
      container.value = { ...container.value, roundMin: +rMin.value, roundMax: +rMax.value }
      emit()
    }, 500)
  }
  rMin.addEventListener("input", updateRound)
  rMax.addEventListener("input", updateRound)
  rMin.addEventListener("blur", () => { clampRound(rMin); updateRound() })
  rMax.addEventListener("blur", () => { clampRound(rMax); updateRound() })

  let dateTimer
  function updateDate() {
    clearTimeout(dateTimer)
    dateTimer = setTimeout(() => {
      // Empty inputs disable that side of the bound — pass through as ""
      // and the data pipeline treats empty as "no min" / "no max".
      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()
    })
  }

  return container
}

teamStatsSeason = teamStatsFilters.season
teamRoundRange = ({ min: teamStatsFilters.roundMin, max: teamStatsFilters.roundMax })
teamDateRange = ({ min: teamStatsFilters.dateMin, max: teamStatsFilters.dateMax })
teamAggMode = teamStatsFilters.aggMode
teamHAFilter = teamStatsFilters.homeAway
teamSeasonType = teamStatsFilters.seasonType
teamVenueFilter = teamStatsFilters.venue
teamDayFilter = teamStatsFilters.day

// Build fixture venue/day lookup
teamFixtureMap = {
  const m = new Map()
  if (fixturesHistory) {
    for (const f of fixturesHistory) {
      const h = predToFull[f.home_team] || f.home_team
      const a = predToFull[f.away_team] || f.away_team
      m.set(`${f.season}-${f.round}-${h}`, { venue: f.venue, start_time: f.start_time })
      m.set(`${f.season}-${f.round}-${a}`, { venue: f.venue, start_time: f.start_time })
    }
  }
  return m
}

// Build H/A lookup from predictions
teamHAMap = {
  const m = new Map()
  if (predictions) {
    for (const p of predictions) {
      const h = predToFull[p.home_team] || p.home_team
      const a = predToFull[p.away_team] || p.away_team
      m.set(`${p.season}-${p.round}-${h}`, "home")
      m.set(`${p.season}-${p.round}-${a}`, "away")
    }
  }
  return m
}
Show code
viewof teamStatsCategory = {
  if (!gameStats) { const e = document.createElement("div"); e.value = "Results"; return e }
  const categories = ["Results", "Overview", "Possession", "Scoring", "Contested", "Midfield", "Defense", "Custom"]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Results"
  const _default = categories.includes(_saved) ? _saved : "Results"
  const wrap = document.createElement("div")
  wrap.className = "stats-category-toggle"
  wrap.value = _default
  for (const cat of categories) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (cat === _default ? " active" : "")
    btn.textContent = cat
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = cat
      window[_key] = cat
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
catConfigs = ({
  Results: {
    columns: ["team", "gp", "w", "l", "d", "win_pct", "pf", "pa", "pd", "gf", "ga"],
    mobileCols: ["team", "w", "l", "win_pct", "pd"],
    header: { team: "Team", gp: "GP", w: "W", l: "L", d: "D", win_pct: "Win%",
              pf: "PF", pa: "PA", pd: "PD", gf: "GF", ga: "GA" },
    groups: [{ label: "", span: 2 }, { label: "Record", span: 4 },
             { label: "Points", span: 3 }, { label: "Goals", span: 2 }],
    tooltip: { gp: "Games played", w: "Wins", l: "Losses", d: "Draws", win_pct: "Win percentage", pf: "Points for", pa: "Points against", pd: "Points differential (PF − PA)", gf: "Goals for", ga: "Goals against" },
    heatmap: { win_pct: "high-good", pd: "high-good", pf: "high-good", pa: "low-good" },
    format: { win_pct: v => v != null ? v.toFixed(1) + "%" : "" },
    sortCol: "win_pct"
  },
  Overview: {
    columns: ["team", "gp", "disposals", "kicks", "handballs", "disposal_eff", "marks", "tackles", "clearances", "inside50s", "goals"],
    mobileCols: ["team", "disposals", "tackles", "clearances", "goals"],
    header: { team: "Team", gp: "GP", disposals: "Disposals", kicks: "Kicks", handballs: "Handballs", disposal_eff: "DE%", marks: "Marks", tackles: "Tackles", clearances: "Clearances", inside50s: "Inside 50s", goals: "Goals" },
    groups: [{ label: "", span: 2 }, { label: "", span: 9 }],
    tooltip: { gp: "Games played", disposals: "Total disposals (kicks + handballs)", kicks: "Total kicks", handballs: "Total handballs", disposal_eff: "Disposal efficiency percentage", marks: "Total marks", tackles: "Tackles applied", clearances: "Disposals from a stoppage", inside50s: "Entries into the attacking forward 50", goals: "Goals kicked" },
    heatmap: { disposals: "high-good", kicks: "high-good", handballs: "high-good", disposal_eff: "high-good", marks: "high-good", tackles: "high-good", clearances: "high-good", inside50s: "high-good", goals: "high-good" },
    sortCol: "disposals"
  },
  Possession: {
    columns: ["team", "gp", "disposals", "kicks", "handballs", "marks", "uncontested_possessions", "clangers", "turnovers"],
    header: { team: "Team", gp: "GP", disposals: "Disposals", kicks: "Kicks", handballs: "Handballs", marks: "Marks", uncontested_possessions: "Uncontest. Poss.", clangers: "Clangers", turnovers: "Turnovers" },
    groups: [{ label: "", span: 2 }, { label: "Possession", span: 7 }],
    tooltip: { gp: "Games played", disposals: "Total disposals (kicks + handballs)", kicks: "Total kicks", handballs: "Total handballs", marks: "Total marks", uncontested_possessions: "Possessions gained without direct contest", clangers: "Turnovers from errors", turnovers: "Total turnovers" },
    heatmap: { disposals: "high-good", kicks: "high-good", handballs: "high-good", marks: "high-good", uncontested_possessions: "high-good", clangers: "low-good", turnovers: "low-good" },
    sortCol: "disposals"
  },
  Scoring: {
    columns: ["team", "gp", "goals", "behinds", "xscore", "shots_at_goal", "xg", "accuracy", "conversion", "shot_quality", "score_minus_xscore", "score_involvements", "goal_assists", "marks_inside50"],
    header: { team: "Team", gp: "GP", goals: "Goals", behinds: "Behinds", xscore: "xScore", shots_at_goal: "Shots", xg: "xG", accuracy: "Acc%", conversion: "Conv%", shot_quality: "Shot Qual", score_minus_xscore: "Score−xS", score_involvements: "Score Inv.", goal_assists: "Goal Assists", marks_inside50: "Marks I50" },
    groups: [{ label: "", span: 2 }, { label: "Scoring", span: 12 }],
    tooltip: { gp: "Games played", goals: "Goals kicked", behinds: "Behinds kicked", xscore: "Expected score from shot quality (goal_prob×6 + behind_prob, summed over shots)", shots_at_goal: "Total shots at goal", xg: "Expected goals — sum of per-shot goal probability", accuracy: "Goal accuracy — goals ÷ shots at goal", conversion: "Conversion — goals ÷ (goals + behinds)", shot_quality: "Average shot quality — xG ÷ shots at goal", score_minus_xscore: "Score minus expected score (goals×6 + behinds − xScore) — finishing above/below shot quality", score_involvements: "Involvements in a scoring chain", goal_assists: "Disposals directly leading to a goal", marks_inside50: "Marks taken inside the forward 50" },
    heatmap: { goals: "high-good", behinds: "high-good", xscore: "high-good", shots_at_goal: "high-good", xg: "high-good", accuracy: "high-good", conversion: "high-good", shot_quality: "high-good", score_minus_xscore: "diverging", score_involvements: "high-good", goal_assists: "high-good", marks_inside50: "high-good" },
    format: { xg: x => (x == null ? "" : (+x).toFixed(1)), xscore: x => (x == null ? "" : (+x).toFixed(1)), accuracy: x => (x == null ? "" : (+x).toFixed(0) + "%"), conversion: x => (x == null ? "" : (+x).toFixed(0) + "%"), shot_quality: x => (x == null ? "" : (+x).toFixed(2)), score_minus_xscore: x => (x == null ? "" : ((+x) >= 0 ? "+" : "") + (+x).toFixed(1)) },
    sortCol: "goals"
  },
  Contested: {
    columns: ["team", "gp", "contested_possessions", "contested_marks", "ground_ball_gets", "frees_for", "frees_against"],
    header: { team: "Team", gp: "GP", contested_possessions: "Contested Poss.", contested_marks: "Contested Marks", ground_ball_gets: "Ground Ball Gets", frees_for: "Frees For", frees_against: "Frees Against" },
    groups: [{ label: "", span: 2 }, { label: "Contested", span: 5 }],
    tooltip: { gp: "Games played", contested_possessions: "Possessions won under direct physical pressure", contested_marks: "Marks taken under direct contest", ground_ball_gets: "Possessions gained from ground-level contests", frees_for: "Free kicks awarded", frees_against: "Free kicks conceded" },
    heatmap: { contested_possessions: "high-good", contested_marks: "high-good", ground_ball_gets: "high-good", frees_for: "high-good", frees_against: "low-good" },
    sortCol: "contested_possessions"
  },
  Midfield: {
    columns: ["team", "gp", "clearances", "inside50s", "rebound50s", "bounces", "metres_gained"],
    header: { team: "Team", gp: "GP", clearances: "Clearances", inside50s: "Inside 50s", rebound50s: "Rebound 50s", bounces: "Bounces", metres_gained: "Metres Gained" },
    groups: [{ label: "", span: 2 }, { label: "Midfield", span: 5 }],
    tooltip: { gp: "Games played", clearances: "Disposals from a stoppage that exit the congestion", inside50s: "Entries into the attacking forward 50", rebound50s: "Exits from the defensive 50", bounces: "Running bounces while in possession", metres_gained: "Net metres advanced toward goal" },
    heatmap: { clearances: "high-good", inside50s: "high-good", rebound50s: "high-good", bounces: "high-good", metres_gained: "high-good" },
    sortCol: "clearances"
  },
  Defense: {
    columns: ["team", "gp", "tackles", "intercepts", "one_percenters", "pressure_acts", "def_half_pressure_acts"],
    header: { team: "Team", gp: "GP", tackles: "Tackles", intercepts: "Intercepts", one_percenters: "One Pct.", pressure_acts: "Pressure Acts", def_half_pressure_acts: "Def Half PA" },
    groups: [{ label: "", span: 2 }, { label: "Defense", span: 5 }],
    tooltip: { gp: "Games played", tackles: "Tackles applied to an opponent in possession", intercepts: "Possessions gained from opposition territory", one_percenters: "Selfless acts — shepherds, smothers, spoils", pressure_acts: "Actions that apply physical or territorial pressure", def_half_pressure_acts: "Pressure acts in the defensive half" },
    heatmap: { tackles: "high-good", intercepts: "high-good", one_percenters: "high-good", pressure_acts: "high-good", def_half_pressure_acts: "high-good" },
    sortCol: "tackles"
  },
  Custom: null
})
Show code
// ── Custom column picker ─────────────────────────────────────
viewof customCols = {
  const allMetrics = []
  const seen = new Set()
  for (const [key, def] of Object.entries(catConfigs)) {
    if (key === "Custom" || !def) continue
    for (const col of def.columns) {
      if (col === "team" || col === "gp" || seen.has(col)) continue
      seen.add(col)
      allMetrics.push({ col, label: def.header[col] || col, cat: key })
    }
  }

  const MAX = 10
  // Restore previously selected columns from localStorage so the user's
  // column set persists across reloads. Per-page key avoids cross-page bleed.
  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)

  // Group by category
  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)

        // Enforce max
        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"
  })

  // Close panel when clicking outside (use AbortController to clean up on OJS re-evaluation)
  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 = teamStatsCategory === "Custom" ? "" : "none"
}
Show code
// ── Build effective category definition for Custom tab ───────
effectiveCatDef = {
  if (teamStatsCategory !== "Custom" || !customCols || customCols.length === 0) return null

  const header = { team: "Team", gp: "GP" }
  const heatmap = {}
  const tooltip = { gp: "Games played" }

  for (const col of customCols) {
    for (const [key, def] of Object.entries(catConfigs)) {
      if (key === "Custom" || !def) continue
      if (def.columns.includes(col)) {
        header[col] = def.header[col] || col
        if (def.heatmap[col]) heatmap[col] = def.heatmap[col]
        if (def.tooltip?.[col]) tooltip[col] = def.tooltip[col]
        break
      }
    }
  }

  return {
    columns: ["team", "gp", ...customCols],
    header,
    groups: [{ label: "", span: 2 }, { label: "Custom", span: customCols.length }],
    heatmap,
    tooltip,
    sortCol: customCols[0]
  }
}
Show code
// Sum player-level stats per team with opponent cross-lookup for results
teamGameStats = {
  // teamStatsSeason === 0 (ALL_SEASONS sentinel) means "no season filter" —
  // the data flows through unfiltered and per-match keys carry season so
  // same-team-vs-team-same-round entries from different seasons don't collide.
  if (!gameStats || teamStatsSeason == null) return null
  let seasonData = teamStatsSeason === 0
    ? gameStats.slice()
    : gameStats.filter(d => Number(d.season) === teamStatsSeason)

  // Round range filter
  if (teamRoundRange.min != null) seasonData = seasonData.filter(d => d.round >= teamRoundRange.min)
  if (teamRoundRange.max != null) seasonData = seasonData.filter(d => d.round <= teamRoundRange.max)

  // Season type filter (Regular / Finals)
  if (teamSeasonType === "Regular") seasonData = seasonData.filter(d => !window.aflTeamMaps.isFinals(d))
  if (teamSeasonType === "Finals") seasonData = seasonData.filter(d => window.aflTeamMaps.isFinals(d))

  if (seasonData.length === 0) return null

  // #216: only surface xG-derived metrics when the rebuilt parquet carries them.
  const haveXg = "xg" in seasonData[0]
  const haveXscore = "xscore" in seasonData[0]

  const statCols = ["disposals", "kicks", "handballs", "marks", "tackles", "clearances",
    "inside50s", "rebound50s", "contested_possessions", "uncontested_possessions",
    "clangers", "turnovers", "metres_gained", "goals", "behinds", "shots_at_goal",
    "score_involvements", "goal_assists", "marks_inside50", "contested_marks",
    "ground_ball_gets", "frees_for", "frees_against", "intercepts", "one_percenters",
    "pressure_acts", "def_half_pressure_acts", "hitouts", "hitouts_to_advantage", "ruck_contests", "bounces",
    "xg", "xscore"]   // #216: per-shot xG/xScore summed per team (added by torpdata)

  // Normalize team/opponent names to display format so cross-lookup works
  const norm = n => predToFull[n] || n

  // Pass 1: per-match per-team totals, keyed by normalized team + season + round.
  // Season is in the key so All-Seasons mode doesn't collide same-round-same-team
  // across years (e.g. Adelaide vs Carlton round 1 in 2024 and 2025 are distinct).
  const matchTeamMap = new Map()  // normalizedTeam|||season|||round -> entry
  for (const row of seasonData) {
    const t = norm(row.team)
    if (!t) continue
    const round = Number(row.round)
    const s = Number(row.season)
    const key = t + "|||" + s + "|||" + round
    if (!matchTeamMap.has(key)) {
      matchTeamMap.set(key, { team: t, season: s, round, opponent: norm(row.opponent), vals: {} })
      for (const c of statCols) matchTeamMap.get(key).vals[c] = 0
    }
    const entry = matchTeamMap.get(key)
    for (const c of statCols) {
      entry.vals[c] = (entry.vals[c] || 0) + (Number(row[c]) || 0)
    }
  }

  // Collect keys to remove, then delete after — avoids mutating Map during iteration
  // which would break opponent cross-lookup in Pass 2
  const keysToDelete = new Set()

  // Home/Away filter — fixture lookup keys are season-scoped, so use mEntry.season
  // (which carries the row's actual season) rather than teamStatsSeason (which is
  // 0 in All-Seasons mode and would never match real fixture keys).
  if (teamHAFilter !== "All") {
    const target = teamHAFilter.toLowerCase()
    for (const [key, mEntry] of matchTeamMap) {
      const ha = teamHAMap.get(`${mEntry.season}-${mEntry.round}-${mEntry.team}`)
      if (ha !== target) keysToDelete.add(key)
    }
  }

  // Venue filter
  if (teamVenueFilter !== "All Venues") {
    for (const [key, mEntry] of matchTeamMap) {
      if (keysToDelete.has(key)) continue
      const fix = teamFixtureMap.get(`${mEntry.season}-${mEntry.round}-${mEntry.team}`)
      if (!fix || fix.venue !== teamVenueFilter) keysToDelete.add(key)
    }
  }

  // Day of week filter
  if (teamDayFilter !== "All Days") {
    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    for (const [key, mEntry] of matchTeamMap) {
      if (keysToDelete.has(key)) continue
      const fix = teamFixtureMap.get(`${mEntry.season}-${mEntry.round}-${mEntry.team}`)
      if (!fix || !fix.start_time) { keysToDelete.add(key); continue }
      const dt = new Date(fix.start_time)
      if (dayNames[dt.getDay()] !== teamDayFilter) keysToDelete.add(key)
    }
  }

  // Date range filter — fixture start_time is ISO ("2026-05-03T14:30:00Z");
  // compare against YYYY-MM-DD bounds via slice(0,10). Empty bound = no limit
  // on that side. Drops rows whose fixture has no start_time so a missing
  // fixture entry can't accidentally pass the filter.
  if (teamDateRange.min || teamDateRange.max) {
    for (const [key, mEntry] of matchTeamMap) {
      if (keysToDelete.has(key)) continue
      const fix = teamFixtureMap.get(`${mEntry.season}-${mEntry.round}-${mEntry.team}`)
      if (!fix || !fix.start_time) { keysToDelete.add(key); continue }
      const d = String(fix.start_time).slice(0, 10)
      if (teamDateRange.min && d < teamDateRange.min) keysToDelete.add(key)
      else if (teamDateRange.max && d > teamDateRange.max) keysToDelete.add(key)
    }
  }

  for (const key of keysToDelete) matchTeamMap.delete(key)

  // Pass 2: aggregate into team totals with results cross-lookup
  const byTeam = new Map()
  for (const [, mEntry] of matchTeamMap) {
    const team = mEntry.team
    // matchId includes season so two same-team-vs-same-team-same-round games
    // from different seasons count as two separate games (relevant only in
    // All-Seasons mode; for single-season mode this is equivalent to the old key).
    const matchId = mEntry.season + "|" + mEntry.round + "|" + mEntry.opponent
    if (!byTeam.has(team)) {
      byTeam.set(team, { games: new Set(), totals: {}, w: 0, l: 0, d: 0, pf: 0, pa: 0, gf: 0, ga: 0 })
      for (const c of statCols) byTeam.get(team).totals[c] = 0
    }
    const entry = byTeam.get(team)
    entry.games.add(matchId)
    for (const c of statCols) entry.totals[c] += mEntry.vals[c]

    // Cross-lookup: opponent's entry, scoped to the same season + round
    const oppKey = mEntry.opponent + "|||" + mEntry.season + "|||" + mEntry.round
    const oppEntry = matchTeamMap.get(oppKey)
    if (!oppEntry) console.warn(`[team-stats] No opponent data for ${mEntry.opponent} in ${mEntry.season} round ${mEntry.round}`)
    const teamScore = (mEntry.vals.goals || 0) * 6 + (mEntry.vals.behinds || 0)
    const oppScore = oppEntry ? ((oppEntry.vals.goals || 0) * 6 + (oppEntry.vals.behinds || 0)) : 0
    entry.pf += teamScore
    entry.pa += oppScore
    entry.gf += mEntry.vals.goals || 0
    entry.ga += oppEntry ? (oppEntry.vals.goals || 0) : 0
    if (teamScore > oppScore) entry.w++
    else if (teamScore < oppScore) entry.l++
    else entry.d++
  }

  // Pass 3: build output rows
  const isAvg = teamAggMode === "avg"
  const teams = []
  for (const [team, entry] of byTeam) {
    const gp = entry.games.size
    if (gp === 0) continue
    const row = { team, gp }
    for (const c of statCols) {
      row[c] = isAvg ? +(entry.totals[c] / gp).toFixed(3) : entry.totals[c]
    }
    // Disposal efficiency (always a percentage)
    const totalDisp = entry.totals.disposals
    const totalClang = entry.totals.clangers
    row.disposal_eff = totalDisp > 0 ? +((totalDisp - totalClang) / totalDisp * 100).toFixed(1) : 0

    // #216 Scoring derived metrics. Ratios are mode-invariant (numerator and
    // denominator scale by gp together); Score−xScore stays mode-consistent
    // because it reads the already-mode-scaled row values. Mirrors player-stats.
    const _g = row.goals, _b = row.behinds, _sa = row.shots_at_goal
    if (_sa > 0) row.accuracy = +(100 * _g / _sa).toFixed(1)
    if (_g + _b > 0) row.conversion = +(100 * _g / (_g + _b)).toFixed(1)
    if (haveXg && _sa > 0) row.shot_quality = +(row.xg / _sa).toFixed(2)
    if (haveXscore && _sa > 0) row.score_minus_xscore = +((_g * 6 + _b) - row.xscore).toFixed(1)

    // Results columns — W/L/D always totals, PF/PA/GF/GA respect aggMode
    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.pf = isAvg ? +(entry.pf / gp).toFixed(1) : entry.pf
    row.pa = isAvg ? +(entry.pa / gp).toFixed(1) : entry.pa
    row.pd = isAvg ? +((entry.pf - entry.pa) / gp).toFixed(1) : entry.pf - entry.pa
    row.gf = isAvg ? +(entry.gf / gp).toFixed(1) : entry.gf
    row.ga = isAvg ? +(entry.ga / gp).toFixed(1) : entry.ga
    teams.push(row)
  }

  return teams
}
Show code
teamAsAtLabel = {
  if (!gameStats) return ""
  if (teamStatsSeason === 0) {
    // All-Seasons mode: anchor the label on the latest season + round actually
    // present in the dataset rather than printing "Round X, 0".
    const seasons = [...new Set(gameStats.map(d => Number(d.season)))].sort((a, b) => b - a)
    const latestSeason = seasons[0]
    const latestRound = Math.max(...gameStats.filter(d => Number(d.season) === latestSeason).map(d => d.round).filter(r => r != null))
    return isFinite(latestRound) ? `All Seasons (latest: Round ${latestRound}, ${latestSeason})` : "All Seasons"
  }
  const seasonData = gameStats.filter(d => Number(d.season) === teamStatsSeason)
  if (seasonData.length === 0) return ""
  const maxRound = Math.max(...seasonData.map(d => d.round).filter(r => r != null))
  return isFinite(maxRound) ? `As at Round ${maxRound}, ${teamStatsSeason}` : ""
}

// page-legend replaced by editorial byline at top of page + table-source row at bottom
""
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
// ── Search ───────────────────────────────────────────────────
viewof teamSearch = teamGameStats ? Inputs.search(teamGameStats, { placeholder: "Search teams…" }) : html``
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  if (!teamGameStats || teamGameStats.length === 0) return html``

  const config = teamStatsCategory === "Custom" ? effectiveCatDef : catConfigs[teamStatsCategory]
  if (!config) return html``

  const statCols = config.columns.filter(c => c !== "team" && c !== "gp" && teamGameStats[0] && teamGameStats[0][c] !== undefined)
  const metricOpts = statCols.map(c => ({ value: c, label: config.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: teamGameStats,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "team",
      format: { [xCol]: v => Number(v).toFixed(1), [yCol]: v => Number(v).toFixed(1) },
      hrefFn: (row) => `team.html#team=${encodeURIComponent(row.team)}`,
      imageFn: (row) => window.aflTeamMaps?.teamLogo(row.team) || null,
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const logo = window.aflTeamMaps.teamLogo(row.team)
        if (logo) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = logo
          badge.alt = ""
          header.appendChild(badge)
        }
        const info = document.createElement("div")
        const nameEl = document.createElement("div")
        nameEl.className = "scatter-tip-name"
        nameEl.textContent = row.team || ""
        info.appendChild(nameEl)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(1)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(1)
        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
{
  if (!teamGameStats) return html``

  const teamLink = window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)

  // Handle Custom tab
  if (teamStatsCategory === "Custom") {
    if (!effectiveCatDef) return html`<p class="text-muted">Select up to 10 columns above to build your custom table.</p>`
    const customEl = statsTable(teamSearch, {
      ...effectiveCatDef,
      tooltip: effectiveCatDef.tooltip || {},
      render: { team: teamLink },
      heatmapData: teamGameStats,
      filters: {
        ...(effectiveCatDef.sortCol ? { [effectiveCatDef.sortCol]: "range" } : {}),
        gp: "range"
      },
      sort: effectiveCatDef.sortCol,
      reverse: true,
      rows: 20
    })
    const wrapC = document.createElement("div")
    wrapC.className = "team-stats-table-view"
    wrapC.style.display = window._teamStatsView === "Table" ? "" : "none"
    wrapC.appendChild(customEl)
    return wrapC
  }

  const config = catConfigs[teamStatsCategory]
  if (!config) return html``

  const tableEl = statsTable(teamSearch, {
    ...config,
    format: config.format || {},
    tooltip: config.tooltip || {},
    render: { team: teamLink },
    heatmapData: teamGameStats,
    filters: {
      ...(config.sortCol ? { [config.sortCol]: "range" } : {}),
      gp: "range"
    },
    sort: config.sortCol,
    reverse: true,
    rows: 20
  })

  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/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = (teamAsAtLabel || "Latest") + " · Aggregated from game-stats.parquet"
  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

  if (!gameStats || gameStats.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 team stats…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  // Compute team-level totals for current season
  let maxSeason = -Infinity
  for (const g of gameStats) { if (g.season > maxSeason) maxSeason = g.season }
  const thisSeason = gameStats.filter(g => g.season === maxSeason)
  const games = thisSeason.length
  const teams = new Set(thisSeason.map(g => g.team).filter(Boolean)).size
  const rounds = new Set(thisSeason.map(g => g.round).filter(r => r != null)).size

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = teamAsAtLabel || "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-level box scores aggregate after each round via 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."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile(games.toLocaleString(), [
    { text: "Team-games logged", bold: true },
    { text: " · in " + maxSeason }
  ]))
  grid.appendChild(btnTile(String(rounds), [
    { text: "Rounds covered", bold: true },
    { text: " · this season" }
  ]))
  grid.appendChild(btnTile(String(teams), [
    { text: "Teams reporting", bold: true },
    { text: " · entire AFL competition" }
  ]))
  grid.appendChild(btnTile("Total · Avg · /Game", [
    { text: "Aggregation modes", bold: true },
    { text: " · toggle in the table controls" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // 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 or pace 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-game team stats" },
    { href: "ladder.html", title: "Ladder & Sims", meta: "Where each team finishes" },
    { 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