diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c113d9b..f8a1313 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -59,6 +59,9 @@ Working: - per-match prediction locking based on each match's kickoff time - tournament leaderboard - spectator standings and knockout progress +- live group standings table derived from finished match scores + (P / W / D / L / GF / GA / GD / Pts), with an optional toggle to view + the same table populated by the current user's predicted ordering ### 2.4 League Participation diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md index 5a98213..bacfb1e 100644 --- a/docs/QA_CHECKLIST.md +++ b/docs/QA_CHECKLIST.md @@ -125,12 +125,22 @@ Expected: 5. Continue through knockout rounds. 6. Save. 7. Return to the tournament page. +8. Switch between the `Groups`, `Standings`, and `Knockout` tabs. +9. In the `Standings` tab, toggle between `Live results` and `My picks`. Expected: - the tournament page recognizes saved predictions - user can return to the prediction wizard before lock - predictions persist on reload +- before any matches are played, the live standings show all zeros + (matching the FIFA standings page baseline) +- once group-stage scores are imported, each group's standings reflect + the running P / W / D / L / GF / GA / GD / Pts derived from finished + matches, sorted by points then goal difference then goals-for +- the `My picks` view shows the user's predicted 1st / 2nd / 3rd / 4th + ordering with stats columns dashed out (predictions store ordering + only, not score predictions) ## 9. Random Fill diff --git a/src/i18n/messages/en.js b/src/i18n/messages/en.js index 36e367f..44a3390 100644 --- a/src/i18n/messages/en.js +++ b/src/i18n/messages/en.js @@ -166,7 +166,32 @@ export const EN = { groups: 'Groups', groupStandings: 'Group standings', standings: 'Standings', + knockout: 'Knockout', knockoutBracket: 'Knockout progress', + standingsViewActual: 'Live results', + standingsViewPicks: 'My picks', + standingsPicksNote: 'Predictions only store finishing order, so stats columns stay empty here.', + standingsEmpty: 'No group-stage matches have been played yet.', + standingsTable: { + rank: '#', + team: 'Team', + played: 'P', + won: 'W', + drawn: 'D', + lost: 'L', + goalsFor: 'GF', + goalsAgainst: 'GA', + goalDiff: 'GD', + points: 'Pts', + playedFull: 'Played', + wonFull: 'Won', + drawnFull: 'Drawn', + lostFull: 'Lost', + goalsForFull: 'Goals for', + goalsAgainstFull: 'Goals against', + goalDiffFull: 'Goal difference', + pointsFull: 'Points', + }, winner: 'Winner', finalTime: 'FT', makeYourPredictions: 'Make Your Predictions', diff --git a/src/i18n/messages/es.js b/src/i18n/messages/es.js index 8194d48..7b618b8 100644 --- a/src/i18n/messages/es.js +++ b/src/i18n/messages/es.js @@ -166,7 +166,32 @@ export const ES = { groups: 'Grupos', groupStandings: 'Posiciones de grupos', standings: 'Posiciones', + knockout: 'Eliminatorias', knockoutBracket: 'Progreso eliminatorio', + standingsViewActual: 'Resultados reales', + standingsViewPicks: 'Mis predicciones', + standingsPicksNote: 'Las predicciones solo guardan el orden final, por eso las columnas de estadísticas quedan vacías aquí.', + standingsEmpty: 'Todavía no se jugaron partidos de la fase de grupos.', + standingsTable: { + rank: '#', + team: 'Equipo', + played: 'PJ', + won: 'G', + drawn: 'E', + lost: 'P', + goalsFor: 'GF', + goalsAgainst: 'GC', + goalDiff: 'DG', + points: 'Pts', + playedFull: 'Partidos jugados', + wonFull: 'Victorias', + drawnFull: 'Empates', + lostFull: 'Derrotas', + goalsForFull: 'Goles a favor', + goalsAgainstFull: 'Goles en contra', + goalDiffFull: 'Diferencia de goles', + pointsFull: 'Puntos', + }, winner: 'Ganador', finalTime: 'FT', makeYourPredictions: 'Haz Tus Predicciones', diff --git a/src/i18n/messages/it.js b/src/i18n/messages/it.js index 261ef1e..5cccd10 100644 --- a/src/i18n/messages/it.js +++ b/src/i18n/messages/it.js @@ -167,7 +167,32 @@ export const IT = { groups: 'Gironi', groupStandings: 'Classifiche gironi', standings: 'Classifica', + knockout: 'Eliminatorie', knockoutBracket: 'Andamento eliminatorie', + standingsViewActual: 'Risultati reali', + standingsViewPicks: 'I miei pronostici', + standingsPicksNote: 'I pronostici memorizzano solo l\'ordine finale, quindi qui le colonne statistiche restano vuote.', + standingsEmpty: 'Non si sono ancora giocate partite della fase a gironi.', + standingsTable: { + rank: '#', + team: 'Squadra', + played: 'PG', + won: 'V', + drawn: 'N', + lost: 'P', + goalsFor: 'GF', + goalsAgainst: 'GS', + goalDiff: 'DR', + points: 'Pt', + playedFull: 'Partite giocate', + wonFull: 'Vittorie', + drawnFull: 'Pareggi', + lostFull: 'Sconfitte', + goalsForFull: 'Gol fatti', + goalsAgainstFull: 'Gol subiti', + goalDiffFull: 'Differenza reti', + pointsFull: 'Punti', + }, winner: 'Vincitore', finalTime: 'FT', makeYourPredictions: 'Inserisci i pronostici', diff --git a/src/i18n/messages/nl.js b/src/i18n/messages/nl.js index a332073..f18dc01 100644 --- a/src/i18n/messages/nl.js +++ b/src/i18n/messages/nl.js @@ -167,7 +167,32 @@ export const NL = { groups: 'Groepen', groupStandings: 'Groepsstanden', standings: 'Stand', + knockout: 'Knock-out', knockoutBracket: 'Voortgang knock-out', + standingsViewActual: 'Echte resultaten', + standingsViewPicks: 'Mijn voorspellingen', + standingsPicksNote: 'Voorspellingen bewaren alleen de eindvolgorde, dus de statistiekkolommen blijven hier leeg.', + standingsEmpty: 'Er zijn nog geen groepsfasewedstrijden gespeeld.', + standingsTable: { + rank: '#', + team: 'Team', + played: 'GS', + won: 'W', + drawn: 'G', + lost: 'V', + goalsFor: 'DV', + goalsAgainst: 'DT', + goalDiff: 'DS', + points: 'Ptn', + playedFull: 'Gespeeld', + wonFull: 'Gewonnen', + drawnFull: 'Gelijk', + lostFull: 'Verloren', + goalsForFull: 'Doelpunten voor', + goalsAgainstFull: 'Doelpunten tegen', + goalDiffFull: 'Doelsaldo', + pointsFull: 'Punten', + }, winner: 'Winnaar', finalTime: 'FT', makeYourPredictions: 'Doe je voorspellingen', diff --git a/src/i18n/messages/pt.js b/src/i18n/messages/pt.js index 8ceb530..4309fab 100644 --- a/src/i18n/messages/pt.js +++ b/src/i18n/messages/pt.js @@ -167,7 +167,32 @@ export const PT = { groups: 'Grupos', groupStandings: 'Classificação dos grupos', standings: 'Classificação', + knockout: 'Mata-mata', knockoutBracket: 'Andamento do mata-mata', + standingsViewActual: 'Resultados reais', + standingsViewPicks: 'Meus palpites', + standingsPicksNote: 'Os palpites guardam apenas a ordem final, por isso as colunas de estatística ficam vazias aqui.', + standingsEmpty: 'Ainda não foram disputados jogos da fase de grupos.', + standingsTable: { + rank: '#', + team: 'Equipa', + played: 'J', + won: 'V', + drawn: 'E', + lost: 'D', + goalsFor: 'GM', + goalsAgainst: 'GS', + goalDiff: 'SG', + points: 'Pts', + playedFull: 'Jogos disputados', + wonFull: 'Vitórias', + drawnFull: 'Empates', + lostFull: 'Derrotas', + goalsForFull: 'Golos marcados', + goalsAgainstFull: 'Golos sofridos', + goalDiffFull: 'Saldo de golos', + pointsFull: 'Pontos', + }, winner: 'Campeão', finalTime: 'FT', makeYourPredictions: 'Faça seus palpites', diff --git a/src/index.css b/src/index.css index 2699821..a486730 100644 --- a/src/index.css +++ b/src/index.css @@ -1814,11 +1814,182 @@ html.theme-light .nav-dropdown-settings, .tournament-league-grid, .tournament-groups-grid, .tournament-knockout-grid, +.tournament-standings-grid, .tournament-owned-leagues { display: grid; gap: 1rem; } +.tournament-view-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.35rem; + border-radius: 1.25rem; + border: 1px solid var(--line-soft); + background: rgba(7, 17, 31, 0.6); +} + +.tournament-view-tabs__tab { + appearance: none; + flex: 1 1 auto; + min-width: 6.5rem; + border: 1px solid transparent; + border-radius: 0.95rem; + padding: 0.55rem 1.05rem; + background: transparent; + color: var(--text-muted); + font-weight: 700; + font-size: 0.95rem; + letter-spacing: 0.01em; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} + +.tournament-view-tabs__tab:hover { + color: var(--text-strong); +} + +.tournament-view-tabs__tab.is-active { + background: rgba(13, 165, 103, 0.18); + border-color: rgba(13, 165, 103, 0.55); + color: var(--text-strong); +} + +.tournament-standings-toggle { + display: inline-flex; + gap: 0.4rem; + padding: 0.3rem; + border-radius: 0.95rem; + border: 1px solid var(--line-soft); + background: rgba(7, 17, 31, 0.55); +} + +.tournament-standings-toggle__btn { + appearance: none; + border: 1px solid transparent; + border-radius: 0.7rem; + padding: 0.4rem 0.85rem; + background: transparent; + color: var(--text-muted); + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} + +.tournament-standings-toggle__btn:hover { + color: var(--text-strong); +} + +.tournament-standings-toggle__btn.is-active { + background: rgba(13, 165, 103, 0.2); + border-color: rgba(13, 165, 103, 0.55); + color: var(--text-strong); +} + +.tournament-standings-note { + color: var(--text-muted); + font-size: 0.9rem; + margin: 0; +} + +.tournament-standings-card { + display: grid; + gap: 0.85rem; + min-height: 100%; + align-content: start; +} + +.tournament-standings-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.tournament-standings-table-wrap { + overflow-x: auto; +} + +.tournament-standings-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; + color: var(--text-strong); +} + +.tournament-standings-table th, +.tournament-standings-table td { + padding: 0.55rem 0.4rem; + text-align: center; + white-space: nowrap; +} + +.tournament-standings-table thead th { + color: var(--text-muted); + font-weight: 700; + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; + border-bottom: 1px solid var(--line-soft); +} + +.tournament-standings-table tbody tr + tr td { + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.tournament-standings-table__rank-col { + width: 2.2rem; + text-align: center; + color: var(--text-muted); + font-weight: 700; +} + +.tournament-standings-table__team-col { + text-align: left; + min-width: 8.5rem; +} + +.tournament-standings-table__team-name { + display: inline-block; + font-weight: 600; +} + +.tournament-standings-table__team-code { + display: inline-block; + margin-inline-start: 0.45rem; + color: var(--text-muted); + font-size: 0.78rem; + letter-spacing: 0.04em; +} + +.tournament-standings-table__points-col { + font-weight: 800; + color: var(--text-strong); +} + +:root[data-theme='light'] .tournament-view-tabs, +:root[data-theme='light'] .tournament-standings-toggle { + background: rgba(255, 255, 255, 0.85); + border-color: rgba(16, 32, 51, 0.12); +} + +:root[data-theme='light'] .tournament-view-tabs__tab, +:root[data-theme='light'] .tournament-standings-toggle__btn { + color: #4f6b83; +} + +:root[data-theme='light'] .tournament-view-tabs__tab.is-active, +:root[data-theme='light'] .tournament-standings-toggle__btn.is-active { + background: rgba(13, 165, 103, 0.16); + color: #0c2236; +} + +:root[data-theme='light'] .tournament-standings-table tbody tr + tr td { + border-top-color: rgba(16, 32, 51, 0.08); +} + .tournament-league-card { min-height: 100%; padding: 1.25rem; @@ -2210,6 +2381,7 @@ html.theme-light .nav-dropdown-settings, .tournament-groups-grid, .tournament-knockout-grid, + .tournament-standings-grid, .tournament-owned-leagues { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2322,7 +2494,8 @@ html.theme-light .nav-dropdown-settings, grid-template-columns: repeat(4, minmax(0, 1fr)); } - .tournament-groups-grid { + .tournament-groups-grid, + .tournament-standings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } diff --git a/src/pages/Tournament.jsx b/src/pages/Tournament.jsx index 2574b8e..617323d 100644 --- a/src/pages/Tournament.jsx +++ b/src/pages/Tournament.jsx @@ -17,6 +17,10 @@ import { resolveMatchParticipants, sortGroups, } from '../utils/tournament'; +import { + computeGroupStandings, + computePredictedGroupStandings, +} from '../utils/standings'; export default function Tournament() { const { id } = useParams(); @@ -28,6 +32,9 @@ export default function Tournament() { const [tournament, setTournament] = useState(null); const [leagues, setLeagues] = useState([]); const [hasPredictions, setHasPredictions] = useState(false); + const [userPredictions, setUserPredictions] = useState(null); + const [activeView, setActiveView] = useState('groups'); + const [standingsView, setStandingsView] = useState('actual'); const [loading, setLoading] = useState(true); const [joining, setJoining] = useState(false); const [joinCode, setJoinCode] = useState(''); @@ -60,6 +67,7 @@ export default function Tournament() { setTournament(tournamentData); setLeagues(leagueData || []); setPrimaryEntry(primaryEntryData); + setUserPredictions(predictionsData); setHasPredictions( Boolean( predictionsData?.groupPredictions?.length || predictionsData?.knockoutPredictions?.length @@ -295,6 +303,19 @@ export default function Tournament() { (round.matches || []).map((match) => [match.id, match.winner || '']) ) ); + const groupStageMatches = groupStageRound?.matches || []; + const standingsByGroupId = new Map( + groups.map((group) => [group.id, computeGroupStandings(group, groupStageMatches)]) + ); + const userGroupPredictionMap = userPredictions?.groupPredictionMap || {}; + const predictedStandingsByGroupId = new Map( + groups.map((group) => [ + group.id, + computePredictedGroupStandings(group, userGroupPredictionMap[group.id] || null), + ]) + ); + const canShowMyPicks = Boolean(user && tournament.access?.canViewPredictions); + const knockoutTabAvailable = rounds.length > 0; const statusLabel = t(`tournament.${tournament.status}`) !== `tournament.${tournament.status}` ? t(`tournament.${tournament.status}`) : tournament.status; @@ -629,6 +650,39 @@ export default function Tournament() { ) : null} +
+ + + {knockoutTabAvailable ? ( + + ) : null} +
+ + {activeView === 'groups' ? (
@@ -780,6 +834,137 @@ export default function Tournament() {
+ ) : null} + + {activeView === 'standings' ? ( +
+ +
+
+ + {t('tournament.standings')} + + + {t('tournament.standings')} + +
+ {canShowMyPicks ? ( +
+ + +
+ ) : null} +
+ + {standingsView === 'picks' && canShowMyPicks ? ( +

+ {t('tournament.standingsPicksNote')} +

+ ) : null} + +
+ {groups.map((group) => { + const showPicks = standingsView === 'picks' && canShowMyPicks; + const rows = showPicks + ? predictedStandingsByGroupId.get(group.id) || [] + : standingsByGroupId.get(group.id) || []; + return ( + +
+ {group.name} +
+
+ + + + + + + + + + + + + + + + + {rows.map((row, index) => { + const dash = '—'; + const rankNumber = showPicks + ? row.predictedRank ?? index + 1 + : index + 1; + const goalDiff = !showPicks && row.goalDiff > 0 + ? `+${formatNumber(row.goalDiff)}` + : !showPicks + ? formatNumber(row.goalDiff) + : dash; + return ( + + + + + + + + + + + + + ); + })} + +
+ {t('tournament.standingsTable.rank')} + + {t('tournament.standingsTable.team')} + {t('tournament.standingsTable.played')}{t('tournament.standingsTable.won')}{t('tournament.standingsTable.drawn')}{t('tournament.standingsTable.lost')}{t('tournament.standingsTable.goalsFor')}{t('tournament.standingsTable.goalsAgainst')}{t('tournament.standingsTable.goalDiff')} + {t('tournament.standingsTable.points')} +
+ {formatNumber(rankNumber)} + + + {getLocalizedName(row.team, language, row.team.name)} + + {row.team.code ? ( + + {row.team.code} + + ) : null} + {showPicks ? dash : formatNumber(row.played)}{showPicks ? dash : formatNumber(row.won)}{showPicks ? dash : formatNumber(row.drawn)}{showPicks ? dash : formatNumber(row.lost)}{showPicks ? dash : formatNumber(row.goalsFor)}{showPicks ? dash : formatNumber(row.goalsAgainst)}{goalDiff} + {showPicks ? dash : formatNumber(row.points)} +
+
+
+ ); + })} +
+
+
+ ) : null}
@@ -857,7 +1042,7 @@ export default function Tournament() {
- {rounds.length ? ( + {activeView === 'knockout' && rounds.length ? (
diff --git a/src/utils/standings.js b/src/utils/standings.js new file mode 100644 index 0000000..198d676 --- /dev/null +++ b/src/utils/standings.js @@ -0,0 +1,174 @@ +// Group-stage standings derivation. +// +// `computeGroupStandings` walks the recorded group_stage matches and produces a +// FIFA-style table: played / won / drawn / lost / goalsFor / goalsAgainst / +// goalDiff / points, sorted by points desc, then goal diff desc, then goals +// for desc, then team name asc. It treats matches as "played" only when both +// homeScore and awayScore have been recorded (or status === 'finished'), so a +// freshly seeded tournament with no results yields all zeros. +// +// `computePredictedGroupStandings` projects a user's group prediction +// (first / second / third placement) onto the same team list. Predictions +// don't carry scores, so it only returns the ordered ranking; the stats +// columns are left for the UI to dash out. + +const NUMERIC_ZERO = 0; + +function emptyRow(team) { + return { + teamId: team.id, + team, + played: NUMERIC_ZERO, + won: NUMERIC_ZERO, + drawn: NUMERIC_ZERO, + lost: NUMERIC_ZERO, + goalsFor: NUMERIC_ZERO, + goalsAgainst: NUMERIC_ZERO, + goalDiff: NUMERIC_ZERO, + points: NUMERIC_ZERO, + }; +} + +function hasRecordedScore(match) { + if (!match) { + return false; + } + + if (match.status === 'finished') { + return Number.isFinite(Number(match.homeScore)) && Number.isFinite(Number(match.awayScore)); + } + + return ( + match.homeScore !== null && + match.homeScore !== undefined && + match.awayScore !== null && + match.awayScore !== undefined && + Number.isFinite(Number(match.homeScore)) && + Number.isFinite(Number(match.awayScore)) + ); +} + +function compareRows(left, right) { + if (right.points !== left.points) { + return right.points - left.points; + } + + if (right.goalDiff !== left.goalDiff) { + return right.goalDiff - left.goalDiff; + } + + if (right.goalsFor !== left.goalsFor) { + return right.goalsFor - left.goalsFor; + } + + return String(left.team?.name || '').localeCompare(String(right.team?.name || '')); +} + +export function computeGroupStandings(group, matches = []) { + const teams = group?.teams || []; + if (teams.length === 0) { + return []; + } + + const codes = new Set(teams.map((team) => team.code).filter(Boolean)); + const byCode = new Map(teams.filter((team) => team.code).map((team) => [team.code, team])); + const rowsById = new Map(teams.map((team) => [team.id, emptyRow(team)])); + + for (const match of matches) { + const homeCode = match?.homeLabel; + const awayCode = match?.awayLabel; + if (!homeCode || !awayCode) { + continue; + } + if (!codes.has(homeCode) || !codes.has(awayCode)) { + continue; + } + if (!hasRecordedScore(match)) { + continue; + } + + const home = byCode.get(homeCode); + const away = byCode.get(awayCode); + if (!home || !away) { + continue; + } + + const homeRow = rowsById.get(home.id); + const awayRow = rowsById.get(away.id); + if (!homeRow || !awayRow) { + continue; + } + + const homeScore = Number(match.homeScore); + const awayScore = Number(match.awayScore); + + homeRow.played += 1; + awayRow.played += 1; + homeRow.goalsFor += homeScore; + homeRow.goalsAgainst += awayScore; + awayRow.goalsFor += awayScore; + awayRow.goalsAgainst += homeScore; + + if (homeScore > awayScore) { + homeRow.won += 1; + awayRow.lost += 1; + homeRow.points += 3; + } else if (homeScore < awayScore) { + awayRow.won += 1; + homeRow.lost += 1; + awayRow.points += 3; + } else { + homeRow.drawn += 1; + awayRow.drawn += 1; + homeRow.points += 1; + awayRow.points += 1; + } + } + + for (const row of rowsById.values()) { + row.goalDiff = row.goalsFor - row.goalsAgainst; + } + + return Array.from(rowsById.values()).sort(compareRows); +} + +export function computePredictedGroupStandings(group, prediction) { + const teams = group?.teams || []; + if (teams.length === 0) { + return []; + } + + const byId = new Map(teams.map((team) => [team.id, team])); + const alphabetical = teams + .slice() + .sort((left, right) => String(left.name || '').localeCompare(String(right.name || ''))); + + if (!prediction) { + return alphabetical.map((team) => ({ teamId: team.id, team, predictedRank: null })); + } + + const ranked = []; + const seen = new Set(); + for (const key of ['first', 'second', 'third']) { + const candidateId = prediction[key]; + if (!candidateId || seen.has(candidateId)) { + continue; + } + const team = byId.get(candidateId); + if (!team) { + continue; + } + ranked.push({ teamId: team.id, team, predictedRank: ranked.length + 1 }); + seen.add(candidateId); + } + + const remaining = alphabetical + .filter((team) => !seen.has(team.id)) + .map((team, index) => ({ + teamId: team.id, + team, + predictedRank: ranked.length + index + 1, + })); + + return [...ranked, ...remaining]; +} diff --git a/tests/standings.test.js b/tests/standings.test.js new file mode 100644 index 0000000..0738144 --- /dev/null +++ b/tests/standings.test.js @@ -0,0 +1,202 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + computeGroupStandings, + computePredictedGroupStandings, +} from '../src/utils/standings.js'; + +function makeGroup() { + return { + id: 'group-a', + name: 'Group A', + teams: [ + { id: 't-mex', name: 'Mexico', code: 'MEX' }, + { id: 't-rsa', name: 'South Africa', code: 'RSA' }, + { id: 't-kor', name: 'Korea Republic', code: 'KOR' }, + { id: 't-cze', name: 'Czechia', code: 'CZE' }, + ], + }; +} + +function match(home, away, homeScore, awayScore, status = 'finished') { + return { + id: `${home}-${away}`, + homeLabel: home, + awayLabel: away, + homeScore, + awayScore, + status, + }; +} + +test('computeGroupStandings returns all zeros when no matches are recorded', () => { + const standings = computeGroupStandings(makeGroup(), []); + + assert.equal(standings.length, 4); + for (const row of standings) { + assert.equal(row.played, 0); + assert.equal(row.won, 0); + assert.equal(row.drawn, 0); + assert.equal(row.lost, 0); + assert.equal(row.goalsFor, 0); + assert.equal(row.goalsAgainst, 0); + assert.equal(row.goalDiff, 0); + assert.equal(row.points, 0); + } + + assert.deepEqual( + standings.map((row) => row.team.code), + ['CZE', 'KOR', 'MEX', 'RSA'], + 'rows are alphabetical by team name when stats are all zero' + ); +}); + +test('computeGroupStandings ignores matches with unrecorded scores', () => { + const matches = [ + match('MEX', 'RSA', null, null, 'scheduled'), + match('KOR', 'CZE', undefined, undefined, 'scheduled'), + ]; + + const standings = computeGroupStandings(makeGroup(), matches); + for (const row of standings) { + assert.equal(row.played, 0); + assert.equal(row.points, 0); + } +}); + +test('computeGroupStandings handles a win, a draw, and a loss', () => { + const matches = [ + match('MEX', 'RSA', 2, 0), + match('KOR', 'CZE', 1, 1), + ]; + + const standings = computeGroupStandings(makeGroup(), matches); + const byCode = Object.fromEntries(standings.map((row) => [row.team.code, row])); + + assert.deepEqual( + { + played: byCode.MEX.played, + won: byCode.MEX.won, + drawn: byCode.MEX.drawn, + lost: byCode.MEX.lost, + goalsFor: byCode.MEX.goalsFor, + goalsAgainst: byCode.MEX.goalsAgainst, + goalDiff: byCode.MEX.goalDiff, + points: byCode.MEX.points, + }, + { + played: 1, won: 1, drawn: 0, lost: 0, + goalsFor: 2, goalsAgainst: 0, goalDiff: 2, points: 3, + } + ); + + assert.deepEqual( + { + played: byCode.RSA.played, + won: byCode.RSA.won, + drawn: byCode.RSA.drawn, + lost: byCode.RSA.lost, + goalsFor: byCode.RSA.goalsFor, + goalsAgainst: byCode.RSA.goalsAgainst, + goalDiff: byCode.RSA.goalDiff, + points: byCode.RSA.points, + }, + { + played: 1, won: 0, drawn: 0, lost: 1, + goalsFor: 0, goalsAgainst: 2, goalDiff: -2, points: 0, + } + ); + + assert.equal(byCode.KOR.drawn, 1); + assert.equal(byCode.CZE.drawn, 1); + assert.equal(byCode.KOR.points, 1); + assert.equal(byCode.CZE.points, 1); +}); + +test('computeGroupStandings sorts by points, then goal diff, then goals for, then name', () => { + // Mexico: 2W 0D 0L GF=4 GA=1 Pts=6 GD=+3 + // Korea: 2W 0D 0L GF=2 GA=0 Pts=6 GD=+2 + // RSA: 0W 0D 2L GF=1 GA=3 Pts=0 GD=-2 + // Czechia: 0W 0D 2L GF=0 GA=3 Pts=0 GD=-3 + const matches = [ + match('MEX', 'RSA', 2, 1), + match('MEX', 'CZE', 2, 0), + match('KOR', 'RSA', 1, 0), + match('KOR', 'CZE', 1, 0), + ]; + + // Both MEX and KOR finish at 6 points; GD favours MEX (+3 > +2). + // Both RSA and CZE finish at 0 points; GD favours RSA (-2 > -3). + const standings = computeGroupStandings(makeGroup(), matches); + + assert.deepEqual( + standings.map((row) => row.team.code), + ['MEX', 'KOR', 'RSA', 'CZE'] + ); +}); + +test('computeGroupStandings breaks tie on goals-for when points and goal diff match', () => { + // Both teams 1W 0D 0L with GD = 1, but different GF. + const matches = [ + match('MEX', 'RSA', 3, 2), + match('KOR', 'CZE', 1, 0), + ]; + + const standings = computeGroupStandings(makeGroup(), matches); + assert.equal(standings[0].team.code, 'MEX', 'MEX wins tiebreaker on goals-for (3 > 1)'); + assert.equal(standings[1].team.code, 'KOR'); +}); + +test('computeGroupStandings ignores matches that belong to other groups', () => { + const matches = [ + match('MEX', 'RSA', 1, 0), + // Foreign codes (Group B match leaked in) — must be skipped silently. + match('CAN', 'BIH', 4, 0), + ]; + + const standings = computeGroupStandings(makeGroup(), matches); + const mex = standings.find((row) => row.team.code === 'MEX'); + assert.equal(mex.played, 1); + // No row was created for CAN/BIH; group is still four teams. + assert.equal(standings.length, 4); +}); + +test('computePredictedGroupStandings ranks a complete prediction first, second, third + remainder', () => { + const prediction = { first: 't-rsa', second: 't-kor', third: 't-mex' }; + const ranked = computePredictedGroupStandings(makeGroup(), prediction); + + assert.deepEqual( + ranked.map((row) => ({ id: row.teamId, rank: row.predictedRank })), + [ + { id: 't-rsa', rank: 1 }, + { id: 't-kor', rank: 2 }, + { id: 't-mex', rank: 3 }, + { id: 't-cze', rank: 4 }, + ] + ); +}); + +test('computePredictedGroupStandings handles no prediction by returning alphabetical, no rank', () => { + const ranked = computePredictedGroupStandings(makeGroup(), null); + + assert.equal(ranked.length, 4); + assert.equal(ranked[0].team.code, 'CZE'); + for (const row of ranked) { + assert.equal(row.predictedRank, null); + } +}); + +test('computePredictedGroupStandings tolerates partial predictions and unknown team ids', () => { + const prediction = { first: 't-rsa', second: 'unknown-id', third: 't-mex' }; + const ranked = computePredictedGroupStandings(makeGroup(), prediction); + + // 't-rsa' first, 't-mex' second (since 'unknown-id' is dropped), + // then alphabetical fillers without overlap. + assert.equal(ranked[0].teamId, 't-rsa'); + assert.equal(ranked[0].predictedRank, 1); + assert.equal(ranked[1].teamId, 't-mex'); + assert.equal(ranked[1].predictedRank, 2); + const remainingIds = ranked.slice(2).map((row) => row.teamId).sort(); + assert.deepEqual(remainingIds, ['t-cze', 't-kor']); +});