HDC
  • Live
  • Teams
  • Playoffs
    • Bracket
    • Cup Odds
    • Simulate
  • About
  • Blog
PPlayoff
WCWild Card
Loading schedule…

Teams

supabase = {
  const { createClient } = await import("https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm")
  return createClient(
    "https://ccapldpwlqeaknodfhdp.supabase.co",
    "sb_publishable_769k5NjCdtqIpajidtDbZg_zNB_h6Vd"
  )
}

seasons = ["20252026","20242025","20232024","20222023","20212022","20202021",
           "20192020","20182019","20172018","20162017","20152016"]

NHL32_ABBREVS = new Set([
  "ANA","BOS","BUF","CGY","CAR","CHI","COL","CBJ","DAL",
  "DET","EDM","FLA","LAK","MIN","MTL","NSH","NJD","NYI","NYR",
  "OTT","PHI","PIT","SEA","SJS","STL","TBL","TOR","UTA","VAN",
  "VGK","WSH","WPG"
])

ABBREV_TO_LOGO = ({
  ANA:"Anaheim_Ducks",       BOS:"Boston_Bruins",
  BUF:"Buffalo_Sabres",      CGY:"Calgary_Flames",         CAR:"Carolina_Hurricanes",
  CHI:"Chicago_Blackhawks",  COL:"Colorado_Avalanche",     CBJ:"Columbus_Blue_Jackets",
  DAL:"Dallas_Stars",        DET:"Detroit_Red_Wings",       EDM:"Edmonton_Oilers",
  FLA:"Florida_Panthers",    LAK:"Los_Angeles_Kings",       MIN:"Minnesota_Wild",
  MTL:"Montreal_Canadiens",  NSH:"Nashville_Predators",     NJD:"New_Jersey_Devils",
  NYI:"New_York_Islanders",  NYR:"New_York_Rangers",        OTT:"Ottawa_Senators",
  PHI:"Philadelphia_Flyers", PIT:"Pittsburgh_Penguins",     SEA:"Seattle_Kraken",
  SJS:"San_Jose_Sharks",     STL:"St._Louis_Blues",         TBL:"Tampa_Bay_Lightning",
  TOR:"Toronto_Maple_Leafs", UTA:"Utah_Mammoth",            VAN:"Vancouver_Canucks",
  VGK:"Vegas_Golden_Knights",WSH:"Washington_Capitals",     WPG:"Winnipeg_Jets"
})
viewof season = Inputs.select(seasons, {
  label: "Season",
  format: s => s.slice(0,4) + "–" + s.slice(4)
})
rawTeams = {
  season
  const { data, error } = await supabase
    .from("team_season_xg")
    .select(`
      wins, losses, otl, expected_wins, expected_losses,
      xg_for, xg_against, goals_for, goals_against,
      shots_for, shots_against,
      teams(name, abbreviation, division, conference)
    `)
    .eq("season", season)
    .eq("game_type", "R")
    .eq("model_name", "xgboost_iso")
  const rows = error ? [] : (data ?? [])
  return rows.filter(r => NHL32_ABBREVS.has(r.teams?.abbreviation))
}
teams = rawTeams.map(r => {
  const gp = (r.wins ?? 0) + (r.losses ?? 0) + (r.otl ?? 0)
  const pts = (r.wins ?? 0) * 2 + (r.otl ?? 0)
  const xgf  = r.xg_for ?? 0
  const xga  = r.xg_against ?? 0
  const gf   = r.goals_for ?? 0
  const ga   = r.goals_against ?? 0
  return {
    ...r,
    gp,
    pts,
    wins_above_expected: (r.wins ?? 0) - (r.expected_wins ?? 0),
    xgf_pct:    xgf + xga > 0 ? (xgf / (xgf + xga) * 100) : 0,
    gf_pct:     gf  + ga  > 0 ? (gf  / (gf  + ga)  * 100) : 0,
    xgf_per_game: gp > 0 ? xgf / gp : 0
  }
})
function logoImg(abbrev, size) {
  const name = ABBREV_TO_LOGO[abbrev]
  if (!name) return html`<span style="width:${size}px;display:inline-block"></span>`
  return html`<img src="/analytics/team-logos/${name}.svg"
    style="width:${size}px;height:${size}px;object-fit:contain;vertical-align:middle"
    alt="${abbrev}"
    onerror="this.style.display='none'">`
}
// ── Over/under performing panels ─────────────────────────────────────
{
  const sorted = [...teams].sort((a,b) => b.wins_above_expected - a.wins_above_expected)
  const over  = sorted.slice(0, 10)
  const under = sorted.slice(-10).reverse()

  function panelRow(t) {
    const abbrev = t.teams?.abbreviation ?? ""
    const name   = t.teams?.name ?? ""
    const val    = t.wins_above_expected
    const sign   = val >= 0 ? "+" : ""
    const cls    = val >= 0 ? "hdc-pos" : "hdc-neg"
    return html`<div style="display:flex;align-items:center;gap:.5rem;padding:.4rem 0;border-bottom:1px solid var(--bs-border-color)">
      ${logoImg(abbrev, 24)}
      <span style="flex:1;font-size:.85rem">${name}</span>
      <span class="${cls}" style="font-weight:700;font-size:.85rem">${sign}${val.toFixed(1)}</span>
    </div>`
  }

  return html`
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
      <div class="hdc-card">
        <div style="font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bs-secondary-color);margin-bottom:.5rem">Overperforming (wins above expected)</div>
        ${over.map(t => panelRow(t))}
      </div>
      <div class="hdc-card">
        <div style="font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bs-secondary-color);margin-bottom:.5rem">Underperforming (wins below expected)</div>
        ${under.map(t => panelRow(t))}
      </div>
    </div>`
}
// ── Full league table — sortable ──────────────────────────────────────
mutable teamSortState = ({col: "pts", dir: "desc"})
{
  function thSort(col, label) {
    const active = teamSortState.col === col
    const icon = active ? (teamSortState.dir === "desc" ? " ▼" : " ▲") : ""
    const th = html`<th class="hdc-th-sort">${label}${active ? html`<span style="color:var(--bs-primary)">${icon}</span>` : ""}</th>`
    th.onclick = () => {
      mutable teamSortState = teamSortState.col === col
        ? { col, dir: teamSortState.dir === "desc" ? "asc" : "desc" }
        : { col, dir: "desc" }
    }
    return th
  }

  const sorted = [...teams].sort((a,b) => {
    const { col, dir } = teamSortState
    const va = a[col] ?? 0, vb = b[col] ?? 0
    const cmp = va < vb ? -1 : va > vb ? 1 : 0
    return dir === "desc" ? -cmp : cmp
  })

  const rows = sorted.map(t => {
    const abbrev = t.teams?.abbreviation ?? ""
    const name   = t.teams?.name ?? ""
    const wax    = t.wins_above_expected
    const waxCls = wax >= 0 ? "hdc-pos" : "hdc-neg"
    const sign   = wax >= 0 ? "+" : ""
    return html`<tr>
      <td>${logoImg(abbrev, 20)} ${abbrev}</td>
      <td>${name}</td>
      <td>${t.gp}</td>
      <td>${t.wins ?? 0}</td>
      <td>${t.losses ?? 0}</td>
      <td>${t.otl ?? 0}</td>
      <td><strong>${t.pts}</strong></td>
      <td>${(t.expected_wins ?? 0).toFixed(1)}</td>
      <td class="${waxCls}"><strong>${sign}${wax.toFixed(1)}</strong></td>
      <td>${t.xgf_pct.toFixed(1)}%</td>
      <td>${t.gf_pct.toFixed(1)}%</td>
      <td>${t.xgf_per_game.toFixed(2)}</td>
    </tr>`
  })

  return html`
    <div style="overflow-x:auto">
      <table class="table table-sm table-hover" style="font-size:.82rem">
        <thead>
          <tr>
            <th>Abbrev</th><th>Team</th>
            ${thSort("gp","GP")}
            ${thSort("wins","W")}
            ${thSort("losses","L")}
            ${thSort("otl","OTL")}
            ${thSort("pts","Pts")}
            ${thSort("expected_wins","xW")}
            ${thSort("wins_above_expected","W−xW")}
            ${thSort("xgf_pct","xGF%")}
            ${thSort("gf_pct","GF%")}
            ${thSort("xgf_per_game","xGF/GP")}
          </tr>
        </thead>
        <tbody>${rows}</tbody>
      </table>
    </div>`
}