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"
})PPlayoff
WCWild Card
Loading schedule…
Teams
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>`
}