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"]
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_Hockey_Club", VAN:"Vancouver_Canucks", VGK:"Vegas_Golden_Knights",
WSH:"Washington_Capitals", WPG:"Winnipeg_Jets"
})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")
return error ? [] : (data ?? [])
}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
}
})// ── Helper: team logo img ────────────────────────────────────────────────
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, isOver) {
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:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bs-secondary-color);margin-bottom:.5rem">Overperforming (W − xW)</div>
${over.map(t => panelRow(t, true))}
</div>
<div class="hdc-card">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bs-secondary-color);margin-bottom:.5rem">Underperforming (W − xW)</div>
${under.map(t => panelRow(t, false))}
</div>
</div>`
}// ── Full league table (sortable) ─────────────────────────────────────────
viewof sortCol = Inputs.select(
["pts","wins_above_expected","xgf_pct","gf_pct","xgf_per_game","wins","gp"],
{ label: "Sort by", format: c => ({
pts:"Points", wins_above_expected:"W−xW", xgf_pct:"xGF%",
gf_pct:"GF%", xgf_per_game:"xGF/GP", wins:"Wins", gp:"GP"
})[c] }
){
const sorted = [...teams].sort((a,b) => (b[sortCol] ?? 0) - (a[sortCol] ?? 0))
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><th>GP</th><th>W</th><th>L</th><th>OTL</th>
<th>Pts</th><th>xW</th><th>W−xW</th><th>xGF%</th><th>GF%</th><th>xGF/GP</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`
}