formatKickoff = (isoDate) => {
if (!isoDate) return ""
const d = new Date(isoDate)
if (isNaN(d)) return ""
// Only show time if the ISO string has a time component (not just a date)
if (isoDate.length <= 11) return ""
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false })
}
// Poisson win probability — delegates to shared football/win-prob.js
poissonWinProb = (xgHome, xgAway) => {
if (window.footballXgProb) {
const r = window.footballXgProb(xgHome, xgAway)
return { pH: r.home, pD: r.draw, pA: r.away }
}
// Inline fallback if win-prob.js hasn't loaded
console.warn("[matches] win-prob.js not loaded, using inline Poisson fallback")
const maxK = 10, fact = [1]
for (let i = 1; i <= maxK; i++) fact[i] = fact[i - 1] * i
const pois = (lambda, k) => Math.exp(-lambda) * Math.pow(lambda, k) / fact[k]
let pH = 0, pD = 0, pA = 0
for (let i = 0; i <= maxK; i++) for (let j = 0; j <= maxK; j++) {
const p = pois(xgHome, i) * pois(xgAway, j)
if (i > j) pH += p; else if (i === j) pD += p; else pA += p
}
return { pH, pD, pA }
}
// Build prediction lookup for enriching fixture-sourced results.
// Keyed on normalized team names so fixture-form URLs ("Leeds United FC") resolve
// to predictions rows that may use a different form ("Leeds United"), which happens
// for pre-2026 historical matches where predictions stores the short form.
predMap = {
if (!predictions) return {}
const norm = window.normalizeTeam
const map = {}
for (const p of predictions) {
const dateKey = (p.match_date || "").replace("Z", "").slice(0, 10)
map[`${p.league}|${dateKey}|${norm(p.home_team)}|${norm(p.away_team)}`] = p
}
return map
}
lookupPred = (league, date, home, away) => {
const norm = window.normalizeTeam
const dateKey = (date || "").slice(0, 10)
return predMap[`${league}|${dateKey}|${norm(home)}|${norm(away)}`] || null
}
leagueMatches = {
if (!selectedLeague) return null
const leagueCode = selectedLeague.value
const today = new Date().toISOString().slice(0, 10)
if (matchView === "Results") {
// Source from fixtures (full season), enrich with predictions where available.
// Accept matches with a score even if status isn't flipped to FINISHED yet
// (football-data.org sometimes lags and leaves freshly-played games as IN_PLAY).
if (!_fixtureData) return null
const finished = _fixtureData.filter(f => {
if (f.league !== leagueCode || f.homeScore == null) return false
if (f.status === "FINISHED") return true
// Fallback: if the match date is in the past and there's a score, treat as played
const fDate = (f.date || "").slice(0, 10)
return fDate && fDate < today
})
return finished
.map(f => {
const dateKey = (f.date || "").slice(0, 10)
const pred = lookupPred(f.league, f.date, f.homeTeam, f.awayTeam)
return {
league: f.league,
match_date: dateKey,
home_team: f.homeTeam,
away_team: f.awayTeam,
match_id: pred?.match_id ?? null, // carry through for Chain Viz link (optaId)
prob_H: pred?.prob_H ?? null,
prob_D: pred?.prob_D ?? null,
prob_A: pred?.prob_A ?? null,
pred_home_goals: pred?.pred_home_goals ?? null,
pred_away_goals: pred?.pred_away_goals ?? null,
xg_home: pred?.xg_home ?? null,
xg_away: pred?.xg_away ?? null,
// Attach fixture data directly so we don't need lookupFixture later
_fixture: f
}
})
.sort((a, b) => (b.match_date || "").localeCompare(a.match_date || ""))
} else {
// Fixtures: upcoming matches from predictions, soonest first. Filter on
// match_date >= today rather than fixture status — fixtures.json is
// current-season only, so historical predictions rows have no fixture row
// to check against a status field.
if (!predictions) return null
const all = predictions.filter(d => {
if (d.league !== leagueCode) return false
const mDate = (d.match_date || "").replace("Z", "").slice(0, 10)
return mDate && mDate >= today
})
return all
.sort((a, b) => {
const dateComp = (a.match_date || "").localeCompare(b.match_date || "")
if (dateComp !== 0) return dateComp
const fa = lookupFixture(a.league, a.match_date, a.home_team, a.away_team)
const fb = lookupFixture(b.league, b.match_date, b.home_team, b.away_team)
if (fa && fb && fa.date && fb.date) return fa.date.localeCompare(fb.date)
return Math.max(b.prob_H, b.prob_A) - Math.max(a.prob_H, a.prob_A)
})
}
}
// Team form lookup — last 5 results per team from fixture data (with match links)
teamForm = {
if (!_fixtureData) return {}
const form = {}
// Sort by date ascending so we can take the last 5
const finished = _fixtureData
.filter(f => f.status === "FINISHED" && f.homeScore != null)
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))
for (const f of finished) {
const league = f.league
const hKey = `${league}|${f.homeTeam}`
const aKey = `${league}|${f.awayTeam}`
if (!form[hKey]) form[hKey] = []
if (!form[aKey]) form[aKey] = []
const url = `match.html#league=${encodeURIComponent(league)}&date=${f.date}&home=${encodeURIComponent(f.homeTeam)}&away=${encodeURIComponent(f.awayTeam)}`
const hResult = f.homeScore > f.awayScore ? "W" : f.homeScore < f.awayScore ? "L" : "D"
const aResult = f.homeScore > f.awayScore ? "L" : f.homeScore < f.awayScore ? "W" : "D"
const scoreStr = ` (${f.homeScore}–${f.awayScore})`
form[hKey].push({ result: hResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${hResult}` })
form[aKey].push({ result: aResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${aResult}` })
}
// Keep last 5
for (const k of Object.keys(form)) form[k] = form[k].slice(-5)
return form
}
formDotsHtml = (league, team) => {
const results = teamForm[`${league}|${team}`]
if (!results || results.length === 0) return ""
const esc = window.statsEsc
return `<span class="form-dots">${results.map(r =>
`<span class="form-dot form-${r.result.toLowerCase()}" data-tip="${esc(r.tip || "")}" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='${r.url}'"></span>`
).join("")}</span>`
}
matchCards = {
if (!leagueMatches || leagueMatches.length === 0) {
const msg = matchView === "Results"
? "No results available for this league yet."
: "No upcoming fixtures for this league."
return html`<p class="text-muted">${msg}</p>`
}
// Load team crests
if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
const crest = window.footballMaps.teamCrest
const badgeHtml = (name) => {
const url = crest(name)
return url ? `<img src="${url}" alt="" class="team-badge" style="width:22px;height:22px;object-fit:contain;vertical-align:middle;margin-right:4px">` : ""
}
const showForm = true // show form dots on both Results and Fixtures (matches AFL pattern)
// Group by date
const byDate = new Map()
for (const m of leagueMatches) {
const d = (m.match_date || "").replace("Z", "")
if (!byDate.has(d)) byDate.set(d, [])
byDate.get(d).push(m)
}
const formatDate = (dateStr) => {
const d = new Date(dateStr + "T12:00:00")
return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" })
}
const sections = []
for (const [date, matches] of byDate) {
sections.push(`<div class="match-date-header">${formatDate(date)}</div>`)
for (const m of matches) {
const f = m._fixture || lookupFixture(m.league, m.match_date, m.home_team, m.away_team)
const hasPred = m.prob_H != null
const homeWin = hasPred && m.prob_H >= m.prob_A && m.prob_H >= m.prob_D
const awayWin = hasPred && m.prob_A > m.prob_H && m.prob_A >= m.prob_D
// Fixture enrichment
const kickoff = f ? formatKickoff(f.date) : ""
const venue = f?.venue || ""
const infoParts = [kickoff, venue].filter(Boolean)
const infoHtml = infoParts.length > 0
? `<div class="match-info">${statsEsc(infoParts.join(" · "))}</div>`
: ""
const finished = f && f.status === "FINISHED" && f.homeScore != null
const isLive = f && (f.status === "IN_PLAY" || f.status === "PAUSED" || f.status === "LIVE")
const hasXg = m.xg_home != null && m.xg_away != null
const scoreHtml = finished
? `<div class="match-score">${f.homeScore} – ${f.awayScore}${hasXg ? `<div class="match-xg">xG: ${m.xg_home.toFixed(1)} – ${m.xg_away.toFixed(1)}</div>` : ""}</div>`
: ""
const matchDateKey = (m.match_date || "").replace("Z", "").slice(0, 10)
const matchHref = `match.html#league=${encodeURIComponent(m.league)}&date=${matchDateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}`
// Prediction + xG bars
const dualBar = finished && hasXg && hasPred
let predBarHtml = ""
if (hasPred) {
predBarHtml = `
<div class="prob-bar-row">
${(dualBar || (finished && hasXg)) ? '<span class="prob-bar-label">Pred</span>' : ''}
<div class="prob-bar-container">
<div class="prob-bar prob-home" style="width: ${m.prob_H * 100}%">
${m.prob_H >= 0.15 ? (m.prob_H * 100).toFixed(0) + '%' : ''}
</div>
<div class="prob-bar prob-draw" style="width: ${m.prob_D * 100}%">
${m.prob_D >= 0.15 ? (m.prob_D * 100).toFixed(0) + '%' : ''}
</div>
<div class="prob-bar prob-away" style="width: ${m.prob_A * 100}%">
${m.prob_A >= 0.15 ? (m.prob_A * 100).toFixed(0) + '%' : ''}
</div>
</div>
</div>`
}
let xgBarHtml = ""
if (finished && hasXg) {
const xgProb = poissonWinProb(m.xg_home, m.xg_away)
xgBarHtml = `
<div class="prob-bar-row">
<span class="prob-bar-label">xG</span>
<div class="prob-bar-container">
<div class="prob-bar prob-home" style="width: ${xgProb.pH * 100}%">
${xgProb.pH >= 0.15 ? (xgProb.pH * 100).toFixed(0) + '%' : ''}
</div>
<div class="prob-bar prob-draw" style="width: ${xgProb.pD * 100}%">
${xgProb.pD >= 0.15 ? (xgProb.pD * 100).toFixed(0) + '%' : ''}
</div>
<div class="prob-bar prob-away" style="width: ${xgProb.pA * 100}%">
${xgProb.pA >= 0.15 ? (xgProb.pA * 100).toFixed(0) + '%' : ''}
</div>
</div>
</div>`
}
const predSection = (predBarHtml || xgBarHtml) ? `
<div class="match-prediction ${dualBar ? 'has-dual-bar' : ''}">
<div class="prob-bars-group">
${predBarHtml}
${xgBarHtml}
</div>
</div>` : ""
const predMargin = m.pred_home_goals != null && m.pred_away_goals != null ? m.pred_home_goals - m.pred_away_goals : null
const predWinner = predMargin != null ? (predMargin > 0.05 ? m.home_team : predMargin < -0.05 ? m.away_team : null) : null
const homeRating = m.pred_home_goals != null ? `<span class="rating">${m.pred_home_goals.toFixed(1)}</span>` : ""
const awayRating = m.pred_away_goals != null ? `<span class="rating">${m.pred_away_goals.toFixed(1)}</span>` : ""
// Build a stable match ID for live DOM updates
const matchId = `${m.league}|${matchDateKey}|${m.home_team}|${m.away_team}`
sections.push(`
<a class="match-card-link football" href="${matchHref}">
<div class="match-card football" data-match-id="${statsEsc(matchId)}">
${infoHtml}
<div class="match-teams">
<div class="team ${homeWin ? 'favoured' : ''}"><span class="team-link" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='team.html#team=${encodeURIComponent(m.home_team)}'">${badgeHtml(m.home_team)}${statsEsc(m.home_team)}</span>
${homeRating}
${showForm ? formDotsHtml(m.league, m.home_team) : ""}
</div>
<div class="match-vs">${finished ? scoreHtml : "vs"}</div>
<div class="team ${awayWin ? 'favoured' : ''}"><span class="team-link" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='team.html#team=${encodeURIComponent(m.away_team)}'">${statsEsc(m.away_team)}${badgeHtml(m.away_team)}</span>
${awayRating}
${showForm ? formDotsHtml(m.league, m.away_team) : ""}
</div>
</div>
${predSection}
<div class="match-card-footer">
${!finished && !isLive && m.pred_home_goals != null ? `<div class="pred-summary">Prediction: ${statsEsc(m.home_team)} ${m.pred_home_goals.toFixed(1)} : ${m.pred_away_goals.toFixed(1)} ${statsEsc(m.away_team)}</div>` : ""}
<div class="match-chain-link"><span class="chain-nav" role="link" tabindex="0" onclick="event.preventDefault();event.stopPropagation();window.location.href='match-chains.html#league=${encodeURIComponent(m.league)}&date=${matchDateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}${m.match_id ? '&optaId=' + encodeURIComponent(m.match_id) : ''}'">Chains →</span></div>
</div>
</div>
</a>`)
}
}
return html`<div class="match-cards-container">${sections.join('')}</div>`
}