diff --git a/assets/css/style.css b/assets/css/style.css
index fb15744..e693055 100644
--- a/assets/css/style.css
+++ b/assets/css/style.css
@@ -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 {
diff --git a/assets/js/groups.js b/assets/js/groups.js
index 608d91f..eab4e1b 100644
--- a/assets/js/groups.js
+++ b/assets/js/groups.js
@@ -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() {
${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')}
+ ${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 `${t(`standings.${key}`)} | `;
+ })
+ .join('');
+
+ return `
+
+
+ ${t('standings.thirdNote')}
+
+ ${t('standings.qualified')}
+ ${t('standings.eliminated')}
+
+
+ `;
+}
+
+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
+ ? `✓`
+ : `—`;
+ return `
+
+ | ${entry.rank} |
+
+
+ ${team.name}
+
+ |
+ ${entry.group} |
+ ${entry.played} |
+ ${entry.won} |
+ ${entry.drawn} |
+ ${entry.lost} |
+ ${entry.gf} |
+ ${entry.ga} |
+ ${entry.gd > 0 ? '+' : ''}${entry.gd} |
+ ${entry.points} |
+ ${status} |
+
`;
+}
+
// Abbreviation key shown only on small screens (where the header hover tooltips
// don't fire); reuses the shared .stats-legend styling.
function legendHTML() {
diff --git a/assets/js/i18n.js b/assets/js/i18n.js
index b75ddb2..a20349a 100644
--- a/assets/js/i18n.js
+++ b/assets/js/i18n.js
@@ -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',