From 68e1c05605af7a7b4123291c64c0407b41bb2489 Mon Sep 17 00:00:00 2001 From: Osvaldo Demo Date: Sun, 14 Jun 2026 21:54:10 -0300 Subject: [PATCH] Surface per-user effective max on tournament and league leaderboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the forfeitedKnockoutPoints field shipped with the closed-group bracket-seeding change. Each row's Total column now reads as `score / per-user maximum` where the maximum is `tournament.rules.totalMaximumPoints − forfeitedKnockoutPoints`. When no group has closed with corrected seeding yet, every player's max equals the tournament-wide max and the column reads the same for all rows. Once a player has at least one knockout match whose actual participants did not include their stored pick, that player's max drops by the round's `pointsPerCorrect` and a single explanatory footnote is rendered below the table. The global leaderboard and profile still show earned totals only — they aggregate across multiple tournaments and a cross-tournament maximum is not yet exposed by the API. Left as a follow-up. - `src/pages/Leaderboard.jsx`, `src/pages/League.jsx`: render `score / effective_max` per row; conditional footnote - `src/index.css`: `.leaderboard-table-footnote` style - `src/i18n/messages/{en,es,pt,it,nl}.js`: `leaderboard.effectiveMaxTooltip` and `leaderboard.effectiveMaxNote` - `docs/QA_CHECKLIST.md`: cover the new display in §14 and §15 - `docs/IMPLEMENTATION_STATUS.md`: note current coverage and the global/profile follow-up Co-authored-by: Cursor --- docs/IMPLEMENTATION_STATUS.md | 6 ++++++ docs/QA_CHECKLIST.md | 9 +++++++++ src/i18n/messages/en.js | 4 ++++ src/i18n/messages/es.js | 4 ++++ src/i18n/messages/it.js | 4 ++++ src/i18n/messages/nl.js | 4 ++++ src/i18n/messages/pt.js | 4 ++++ src/index.css | 8 ++++++++ src/pages/Leaderboard.jsx | 26 ++++++++++++++++++++++++++ src/pages/League.jsx | 18 ++++++++++++++++++ 10 files changed, 87 insertions(+) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 57c1fed..b3233cb 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -68,6 +68,12 @@ Working: advanced; knockout matches whose participants did not include the user's stored pick are tracked as `forfeitedKnockoutPoints` and excluded from their per-user maximum-possible score +- tournament and league leaderboards render the Total column as + `score / per-user maximum possible` + (`tournament.rules.totalMaximumPoints − forfeitedKnockoutPoints`) + with an inline footnote when any player has forfeited points; the + global leaderboard and profile still display earned totals only + because a cross-tournament maximum is not yet exposed ### 2.4 League Participation diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md index 7703cac..a803d0d 100644 --- a/docs/QA_CHECKLIST.md +++ b/docs/QA_CHECKLIST.md @@ -284,6 +284,13 @@ Expected: - tournament leaderboard reflects tournament-scope scores - round columns match the structure - prize UI only appears when enabled +- the Total column renders as `score / per-user max`. When no group has + closed with corrected seeding yet, every row's max equals + `tournament.rules.totalMaximumPoints`. After a group is closed and at + least one user has a knockout pick whose actual participants did not + include their stored team, that row's max drops by + `pointsPerCorrect` for each such match and an explanatory footnote + appears below the table ## 15. League Leaderboard @@ -296,6 +303,8 @@ Expected: - league leaderboard is filtered to that league - tournament leaderboard is not replaced by league-only results +- the Total column on the league board renders the same + `score / per-user max` treatment as the tournament leaderboard ## 16. League Prediction Copy Flow diff --git a/src/i18n/messages/en.js b/src/i18n/messages/en.js index 43d77c5..9da2116 100644 --- a/src/i18n/messages/en.js +++ b/src/i18n/messages/en.js @@ -344,6 +344,10 @@ export const EN = { sfPts: 'SF', finalPts: 'Final', total: 'Total', + effectiveMaxTooltip: + 'Score / your maximum possible. Knockout matches whose actual teams did not include your stored pick are excluded from your maximum.', + effectiveMaxNote: + 'Total shows score / per-user maximum. Knockout matches whose actual teams did not include your stored pick are excluded from your maximum.', you: 'You', prizePool: 'Prize Pool', participants: 'Participants', diff --git a/src/i18n/messages/es.js b/src/i18n/messages/es.js index d330193..7255331 100644 --- a/src/i18n/messages/es.js +++ b/src/i18n/messages/es.js @@ -344,6 +344,10 @@ export const ES = { sfPts: 'SF', finalPts: 'Final', total: 'Total', + effectiveMaxTooltip: + 'Puntaje / tu máximo posible. Los partidos de eliminación cuyos equipos reales no incluyen tu pick guardado quedan fuera de tu máximo.', + effectiveMaxNote: + 'El total muestra puntaje / máximo posible por usuario. Los partidos de eliminación cuyos equipos reales no incluyen tu pick guardado quedan fuera de tu máximo.', you: 'Tú', prizePool: 'Fondo de Premios', participants: 'Participantes', diff --git a/src/i18n/messages/it.js b/src/i18n/messages/it.js index fa012ed..a5703fb 100644 --- a/src/i18n/messages/it.js +++ b/src/i18n/messages/it.js @@ -346,6 +346,10 @@ export const IT = { sfPts: 'Semi.', finalPts: 'Finale', total: 'Totale', + effectiveMaxTooltip: + 'Punteggio / massimo possibile. Le partite a eliminazione le cui squadre reali non includono la tua scelta salvata sono escluse dal tuo massimo.', + effectiveMaxNote: + 'Il totale mostra punteggio / massimo possibile per utente. Le partite a eliminazione le cui squadre reali non includono la tua scelta salvata sono escluse dal tuo massimo.', you: 'Tu', prizePool: 'Montepremi', participants: 'Partecipanti', diff --git a/src/i18n/messages/nl.js b/src/i18n/messages/nl.js index e0eba6b..5abb60b 100644 --- a/src/i18n/messages/nl.js +++ b/src/i18n/messages/nl.js @@ -346,6 +346,10 @@ export const NL = { sfPts: 'Half', finalPts: 'Finale', total: 'Totaal', + effectiveMaxTooltip: + 'Score / jouw maximaal mogelijke. Knock-outwedstrijden waarvan de echte teams jouw opgeslagen keuze niet bevatten, tellen niet mee voor jouw maximum.', + effectiveMaxNote: + 'Totaal toont score / persoonlijk maximum. Knock-outwedstrijden waarvan de echte teams jouw opgeslagen keuze niet bevatten, tellen niet mee voor jouw maximum.', you: 'Jij', prizePool: 'Prijzenpot', participants: 'Deelnemers', diff --git a/src/i18n/messages/pt.js b/src/i18n/messages/pt.js index a0c747a..e0dda01 100644 --- a/src/i18n/messages/pt.js +++ b/src/i18n/messages/pt.js @@ -346,6 +346,10 @@ export const PT = { sfPts: 'Semi.', finalPts: 'Final', total: 'Total', + effectiveMaxTooltip: + 'Pontuação / seu máximo possível. Os jogos de mata-mata cujas equipes reais não incluem o seu palpite guardado ficam fora do seu máximo.', + effectiveMaxNote: + 'O total mostra pontuação / máximo possível por usuário. Os jogos de mata-mata cujas equipes reais não incluem o seu palpite guardado ficam fora do seu máximo.', you: 'Você', prizePool: 'Premiação', participants: 'Participantes', diff --git a/src/index.css b/src/index.css index a486730..f60cb1c 100644 --- a/src/index.css +++ b/src/index.css @@ -3004,6 +3004,14 @@ html.theme-light .nav-dropdown-settings, overflow: hidden; } +.leaderboard-table-footnote { + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.45; + margin: 0; + padding: 0.75rem 1.5rem 1.25rem; +} + .app-table { width: 100%; border-collapse: collapse; diff --git a/src/pages/Leaderboard.jsx b/src/pages/Leaderboard.jsx index 90f8d9a..d9c321f 100644 --- a/src/pages/Leaderboard.jsx +++ b/src/pages/Leaderboard.jsx @@ -105,6 +105,15 @@ export default function Leaderboard() { ? currentLeague.description || t('leaderboard.leagueDescription') : tournamentName || t('home.noTournaments'); + // The tournament-wide maximum a perfect bracket can score. We use it + // together with each player's `forfeitedKnockoutPoints` (knockout matches + // whose actual participants did not include the player's stored pick) to + // render a per-user effective max alongside the total. + const tournamentMaxPoints = tournament?.rules?.totalMaximumPoints || 0; + const someoneForfeited = players.some( + (player) => (player.forfeitedKnockoutPoints || 0) > 0 + ); + if (loading) { return ( @@ -291,6 +300,10 @@ export default function Leaderboard() { {players.map((player, index) => { const isCurrentUser = user?.id === player.userId; + const forfeited = player.forfeitedKnockoutPoints || 0; + const effectiveMax = tournamentMaxPoints + ? Math.max(0, tournamentMaxPoints - forfeited) + : 0; return ( {formatNumber(player.totalScore || 0)} + {tournamentMaxPoints ? ( + + {` / ${formatNumber(effectiveMax)}`} + + ) : null} ); @@ -339,6 +360,11 @@ export default function Leaderboard() { + {someoneForfeited ? ( +

+ {t('leaderboard.effectiveMaxNote')} +

+ ) : null} )}
diff --git a/src/pages/League.jsx b/src/pages/League.jsx index e28e62b..e19378c 100644 --- a/src/pages/League.jsx +++ b/src/pages/League.jsx @@ -530,6 +530,11 @@ export default function League() { {players.map((player, index) => { const isCurrentUser = user?.id === player.userId; + const forfeited = player.forfeitedKnockoutPoints || 0; + const tournamentMaxPoints = tournament?.rules?.totalMaximumPoints || 0; + const effectiveMax = tournamentMaxPoints + ? Math.max(0, tournamentMaxPoints - forfeited) + : 0; return ( {formatNumber(player.totalScore || 0)} + {tournamentMaxPoints ? ( + + {` / ${formatNumber(effectiveMax)}`} + + ) : null} ); @@ -572,6 +585,11 @@ export default function League() { + {players.some((player) => (player.forfeitedKnockoutPoints || 0) > 0) ? ( +

+ {t('leaderboard.effectiveMaxNote')} +

+ ) : null} )}