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"
})
// Map from nhl_team_id -> abbreviation (built from game data)
teamIdToAbbrev = new Map()Loading schedule…
Games
viewof season = Inputs.select(seasons, {
label: "Season",
format: s => s.slice(0,4) + "–" + s.slice(4)
})rawGames = {
season
const { data, error } = await supabase
.from("game_xg_summary_view")
.select(`
game_id, game_date, home_score, away_score,
home_xg, away_xg, home_xg_timeline, away_xg_timeline,
home_team_id, away_team_id
`)
.eq("season", season)
.eq("game_type", "R")
.eq("model_name", "xgboost_iso")
.not("home_score", "is", null)
.order("game_date", { ascending: false })
return error ? [] : (data ?? [])
}// ── Enrich with team abbreviations from the teams table ──────────────────
teamData = {
rawGames
const ids = [...new Set([
...rawGames.map(g => g.home_team_id),
...rawGames.map(g => g.away_team_id)
].filter(Boolean))]
if (!ids.length) return {}
const { data } = await supabase
.from("teams")
.select("nhl_team_id, abbreviation, name")
.in("nhl_team_id", ids)
const map = {}
for (const t of (data ?? [])) map[t.nhl_team_id] = t
return map
}games = rawGames.map(g => {
const home = teamData[g.home_team_id] ?? {}
const away = teamData[g.away_team_id] ?? {}
const homeXg = g.home_xg ?? 0
const awayXg = g.away_xg ?? 0
const homeScore = g.home_score ?? 0
const awayScore = g.away_score ?? 0
const homeWon = homeScore > awayScore
const upset = homeWon
? awayXg - homeXg
: homeXg - awayXg
return {
...g,
home_abbrev: home.abbreviation ?? "?",
away_abbrev: away.abbreviation ?? "?",
home_name: home.name ?? "",
away_name: away.name ?? "",
total_xg: homeXg + awayXg,
upset_magnitude: upset
}
})// ── Spotlight panels ──────────────────────────────────────────────────────
{
const upsets = [...games].sort((a,b) => b.upset_magnitude - a.upset_magnitude).slice(0, 10)
const highXg = [...games].sort((a,b) => b.total_xg - a.total_xg).slice(0, 10)
function logoImg(abbrev) {
const name = ABBREV_TO_LOGO[abbrev]
if (!name) return html`<span style="width:20px;display:inline-block"></span>`
return html`<img src="/analytics/team-logos/${name}.svg" style="width:20px;height:20px;object-fit:contain;vertical-align:middle" alt="${abbrev}" onerror="this.style.display='none'">`
}
function upsetRow(g) {
const score = `${g.away_score}–${g.home_score}`
return html`<div style="font-size:.82rem;padding:.35rem 0;border-bottom:1px solid var(--bs-border-color);display:flex;align-items:center;gap:.4rem">
<span class="hdc-upset-badge">UPSET</span>
${logoImg(g.away_abbrev)} <strong>${g.away_abbrev}</strong>
<span style="color:var(--bs-secondary-color)">${score}</span>
${logoImg(g.home_abbrev)} <strong>${g.home_abbrev}</strong>
<span style="flex:1"></span>
<span style="color:var(--bs-secondary-color);font-size:.75rem">xG ${g.away_xg?.toFixed(2)} – ${g.home_xg?.toFixed(2)}</span>
</div>`
}
function highXgRow(g) {
const score = `${g.away_score}–${g.home_score}`
return html`<div style="font-size:.82rem;padding:.35rem 0;border-bottom:1px solid var(--bs-border-color);display:flex;align-items:center;gap:.4rem">
${logoImg(g.away_abbrev)} <strong>${g.away_abbrev}</strong>
<span style="color:var(--bs-secondary-color)">${score}</span>
${logoImg(g.home_abbrev)} <strong>${g.home_abbrev}</strong>
<span style="flex:1"></span>
<span style="color:var(--bs-primary);font-size:.75rem;font-weight:700">xG ${g.total_xg.toFixed(2)}</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">Biggest Upsets</div>
${upsets.map(upsetRow)}
</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">Highest xG Games</div>
${highXg.map(highXgRow)}
</div>
</div>`
}// ── Expanded game state ─────────────���────────────────────────────────────
mutable expandedGameId = null// ── Full game log ─────────────────────────────────────────────────────────
{
function logoImg(abbrev) {
const name = ABBREV_TO_LOGO[abbrev]
if (!name) return html`<span style="width:18px;display:inline-block"></span>`
return html`<img src="/analytics/team-logos/${name}.svg" style="width:18px;height:18px;object-fit:contain;vertical-align:middle" alt="${abbrev}" onerror="this.style.display='none'">`
}
const rows = games.map(g => {
const isExpanded = expandedGameId === g.game_id
const score = `${g.away_score}–${g.home_score}`
const dateStr = g.game_date ? g.game_date.slice(0, 10) : ""
const gameRow = html`<tr style="cursor:pointer">
<td style="color:var(--bs-secondary-color);font-size:.78rem">${dateStr}</td>
<td>${logoImg(g.away_abbrev)} ${g.away_abbrev}</td>
<td>${logoImg(g.home_abbrev)} ${g.home_abbrev}</td>
<td style="font-weight:700">${score}</td>
<td style="color:var(--bs-secondary-color)">${g.away_xg?.toFixed(2) ?? "—"}</td>
<td style="color:var(--bs-secondary-color)">${g.home_xg?.toFixed(2) ?? "—"}</td>
<td style="color:var(--bs-secondary-color);font-size:.75rem">${isExpanded ? "▲" : "▼"}</td>
</tr>`
gameRow.addEventListener("click", () => {
mutable expandedGameId = isExpanded ? null : g.game_id
})
if (!isExpanded) return gameRow
// Build xG flow chart from timeline JSONB
const awayTimeline = g.away_xg_timeline ?? []
const homeTimeline = g.home_xg_timeline ?? []
const chart = Plot.plot({
width: 600,
height: 220,
style: { background: "transparent", color: "var(--bs-body-color)" },
x: { label: "Game seconds", domain: [0, 3600] },
y: { label: "Cumulative xG" },
marks: [
Plot.ruleX([1200, 2400], { stroke: "var(--bs-border-color)", strokeDasharray: "4,4" }),
Plot.lineY(awayTimeline, { x: "game_seconds", y: "cumulative_xg", stroke: "#FF4C00", strokeWidth: 2 }),
Plot.lineY(homeTimeline, { x: "game_seconds", y: "cumulative_xg", stroke: "#4a9eff", strokeWidth: 2 }),
Plot.dot(awayTimeline.filter(d => d.is_goal), { x: "game_seconds", y: "cumulative_xg", r: 4, fill: "#FF4C00" }),
Plot.dot(homeTimeline.filter(d => d.is_goal), { x: "game_seconds", y: "cumulative_xg", r: 4, fill: "#4a9eff" })
]
})
const legend = html`<div style="display:flex;gap:1rem;font-size:.75rem;margin-top:.25rem">
<span><span style="display:inline-block;width:14px;height:3px;background:#FF4C00;vertical-align:middle;margin-right:4px"></span>${g.away_abbrev}</span>
<span><span style="display:inline-block;width:14px;height:3px;background:#4a9eff;vertical-align:middle;margin-right:4px"></span>${g.home_abbrev}</span>
</div>`
const expandedRow = html`<tr>
<td colspan="7" style="padding:1rem;background:var(--bs-body-bg)">
${chart}${legend}
</td>
</tr>`
return [gameRow, expandedRow]
})
return html`
<div style="overflow-x:auto">
<table class="table table-sm table-hover" style="font-size:.82rem">
<thead>
<tr>
<th>Date</th><th>Away</th><th>Home</th><th>Score</th>
<th>Away xG</th><th>Home xG</th><th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`
}