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} +
+ {t('tournament.standingsPicksNote')} +
+ ) : null} + +| + {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)} + | +