feat(groups): add best third-placed teams ranking table

Ranks the 12 third-placed teams across groups (Pts -> GD -> GF -> id) and marks the top 8 that advance to the R32. Full-width section below the group cards in the Grupos tab, gated on all 12 groups finished (omitted from the DOM otherwise). Reuses .standings-table styling, header tooltips and the favorite-row highlight: gold rows + check for the 8 qualified, muted rows for 9-12, a dashed cut line between. computeThirdPlaceRanking() only ranks for display; the slot->group allocation stays in bracket-config.json. Bumps APP_VERSION to v1.0.3 (also covers the hero knockout-resolution fix).
This commit is contained in:
Lucas Kalil 2026-06-28 13:16:14 -03:00
parent 22a157197b
commit adb8cce441
3 changed files with 162 additions and 1 deletions

View file

@ -785,6 +785,65 @@ button {
}
}
/* best third-placed teams table (shown once all 12 groups finish) */
.third-place {
margin-top: 1rem;
padding: 1rem 1.1rem 1.2rem;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(16, 36, 59, 0.55);
}
.third-place-note {
margin: 0 0 0.7rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.third-place-keys {
margin-bottom: 0.7rem;
}
.legend-dot.out {
background: var(--text-secondary);
opacity: 0.45;
}
.third-place-scroll {
overflow-x: auto;
}
.standings-table .col-group {
font-weight: 600;
color: var(--accent-gold);
}
.third-place-table tr.row-third td:first-child {
box-shadow: inset 3px 0 0 var(--accent-gold);
}
.third-place-table tr.row-out td {
color: var(--text-secondary);
opacity: 0.6;
}
.third-place-table tr.cut {
border-top: 2px dashed rgba(255, 255, 255, 0.22);
}
.third-place-table .col-status {
width: 1.6rem;
}
.third-place-table .qual-yes {
color: var(--accent-gold);
font-weight: 700;
}
.third-place-table .qual-no {
color: var(--text-secondary);
}
/* ------------------------------------------------------ stadiums */
.stadiums-grid {

View file

@ -52,6 +52,24 @@ export function isGroupFinished(letter) {
.every((m) => resultByMatchId.get(m.id)?.status === 'finished');
}
function allGroupsFinished() {
return Object.keys(getData().groups).every(isGroupFinished);
}
// Best third-placed teams: each group's 3rd-placed row, ranked across all 12
// groups by the same criteria as within a group (points → GD → GF → id). The top
// 8 advance to the Round of 32. This only ranks the thirds for display — the
// slot→group allocation itself lives in bracket-config.json (filled from FIFA's
// official combination table), not derived here.
export function computeThirdPlaceRanking() {
const standings = computeStandings();
return Object.entries(standings)
.map(([letter, rows]) => ({ ...rows[2], group: letter }))
.sort((a, b) =>
b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId))
.map((row, i) => ({ ...row, rank: i + 1, qualified: i < 8 }));
}
// -------------------------------------------------------------- render
export function initGroups() {
@ -71,9 +89,83 @@ function render() {
<div class="groups-grid">
${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')}
</div>
${allGroupsFinished() ? thirdPlaceSectionHTML() : ''}
${legendHTML()}`;
}
// Best-third ranking table — rendered only once all 12 groups are finished (the
// ranking is meaningless mid-stage). Reuses the .standings-table styling, the
// header tooltips and the favorite-row highlight from the group cards.
function thirdPlaceSectionHTML() {
const ranking = computeThirdPlaceRanking();
const headers = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
.map((key) => {
const tip = t(`tip.${key}`);
const goals = key === 'gf' || key === 'ga' ? 'col-goals ' : '';
return `<th class="${goals}has-tip" scope="col" data-tip="${tip}" aria-label="${t(`standings.${key}`)}${tip}">${t(`standings.${key}`)}</th>`;
})
.join('');
return `
<section class="third-place glass" aria-labelledby="third-place-title">
<header class="group-card-header">
<h3 id="third-place-title">${t('standings.thirdTitle')}</h3>
</header>
<p class="third-place-note">${t('standings.thirdNote')}</p>
<p class="standings-legend third-place-keys">
<span class="legend-item"><span class="legend-dot third"></span>${t('standings.qualified')}</span>
<span class="legend-item"><span class="legend-dot out"></span>${t('standings.eliminated')}</span>
</p>
<div class="third-place-scroll">
<table class="standings-table third-place-table">
<thead>
<tr>
<th scope="col">#</th>
<th class="col-team" scope="col">${t('standings.team')}</th>
<th class="col-group has-tip" scope="col" data-tip="${t('standings.group')}" aria-label="${t('standings.group')}">${t('standings.group')}</th>
${headers}
<th class="col-status" scope="col" aria-label="${t('standings.qualified')}"></th>
</tr>
</thead>
<tbody>${ranking.map(thirdRowHTML).join('')}</tbody>
</table>
</div>
</section>`;
}
function thirdRowHTML(entry, index) {
const team = getData().teamById.get(entry.teamId);
const fav = getFavorites().includes(team.id);
const cls = [
entry.qualified ? 'row-third' : 'row-out',
index === 8 ? 'cut' : '',
fav ? 'fav-row' : '',
].filter(Boolean).join(' ');
const status = entry.qualified
? `<span class="qual-yes" aria-label="${t('standings.qualified')}">✓</span>`
: `<span class="qual-no" aria-label="${t('standings.eliminated')}">—</span>`;
return `
<tr class="${cls}">
<td>${entry.rank}</td>
<td class="col-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
<span>${team.name}</span>
<button class="fav-btn ${fav ? 'active' : ''}" data-fav="${team.id}"
aria-pressed="${fav}" aria-label="${t('fav.toggle')} ${team.name}">${fav ? '★' : '☆'}</button>
</td>
<td class="col-group">${entry.group}</td>
<td>${entry.played}</td>
<td>${entry.won}</td>
<td>${entry.drawn}</td>
<td>${entry.lost}</td>
<td class="col-goals">${entry.gf}</td>
<td class="col-goals">${entry.ga}</td>
<td>${entry.gd > 0 ? '+' : ''}${entry.gd}</td>
<td class="col-pts">${entry.points}</td>
<td class="col-status">${status}</td>
</tr>`;
}
// Abbreviation key shown only on small screens (where the header hover tooltips
// don't fire); reuses the shared .stats-legend styling.
function legendHTML() {

View file

@ -6,7 +6,7 @@
import { getPrefs, setPref } from './storage.js';
// App version for footer display — bump this after any notable changes
const APP_VERSION = 'v1.0.2';
const APP_VERSION = 'v1.0.3';
const dicts = {
en: {
@ -73,6 +73,11 @@ const dicts = {
'standings.legendTop2': 'Advance to the Round of 32',
'standings.legendThird': 'In contention for best third place',
'standings.inProgress': 'In progress',
'standings.thirdTitle': 'Best third-placed teams',
'standings.thirdNote': '8 of 12 advance to the Round of 32 — ranked by points, then goal difference, then goals for.',
'standings.group': 'Group',
'standings.qualified': 'Qualified',
'standings.eliminated': 'Eliminated',
'stadiums.capacity': 'Capacity',
'stadiums.viewMatches': 'View matches',
'status.scheduled': 'Scheduled',
@ -248,6 +253,11 @@ const dicts = {
'standings.legendTop2': 'Avançam aos 16 avos de final',
'standings.legendThird': 'Na briga por melhor 3º lugar',
'standings.inProgress': 'Em andamento',
'standings.thirdTitle': 'Melhores terceiros colocados',
'standings.thirdNote': '8 de 12 avançam aos 16 avos de final — ordenados por pontos, depois saldo de gols, depois gols pró.',
'standings.group': 'Grupo',
'standings.qualified': 'Classificado',
'standings.eliminated': 'Eliminado',
'stadiums.capacity': 'Capacidade',
'stadiums.viewMatches': 'Ver partidas',
'status.scheduled': 'Agendada',