From 3138c0b9cc41f2e4dfcb9619403d00c28ec8f881 Mon Sep 17 00:00:00 2001 From: Osvaldo Demo Date: Sun, 14 Jun 2026 21:32:33 -0300 Subject: [PATCH] Loosen group placement lock to the last group match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a group's 1°/2°/3° placement card locked as soon as the first group-stage match between two of its teams kicked off. By matchday 1 every group already shows "Grupo bloqueado", which makes it impossible to refine placement picks as the matchdays unfold even when most fixtures are still in the future. Move the cutoff to the latest per-match-safe point: a group now stays editable while *any* of its group-stage matches is still unlocked, and only locks once every such match has kicked off or been manually closed by an admin. Placement therefore stays a genuine prediction (one fixture is still uncertain when the lock fires) without trivially giving the user the final standings. Per-match locking semantics are unchanged: each individual match still locks at its own kickoff or when admin closes it. The relaxed rule only governs the derived group-level lock. i18n strings updated in en / es / pt / it / nl. ARCHITECTURE §7.5 and QA_CHECKLIST §11.1/§11.2 updated to describe the new cutoff and to add a mixed-state test step. New unit coverage in locking.test.mjs verifies that a partially-started group stays open, that the group locks once every match has started, and that a single admin-closed match no longer locks the whole group on its own. Co-authored-by: Cursor --- api/locking.cjs | 23 +++++---- docs/ARCHITECTURE.md | 15 ++++-- docs/QA_CHECKLIST.md | 25 ++++++--- src/i18n/messages/en.js | 2 +- src/i18n/messages/es.js | 2 +- src/i18n/messages/it.js | 2 +- src/i18n/messages/nl.js | 2 +- src/i18n/messages/pt.js | 2 +- tests/locking.test.mjs | 110 ++++++++++++++++++++++++++++++++++++---- 9 files changed, 147 insertions(+), 36 deletions(-) diff --git a/api/locking.cjs b/api/locking.cjs index 0fd80af..1f8fa24 100644 --- a/api/locking.cjs +++ b/api/locking.cjs @@ -4,8 +4,10 @@ // frontend which matches and groups should render as read-only). // // Locking is strictly per-match. A match locks once its kickoff has passed -// or an admin has flipped `predictionsClosed` on it; a group locks once any -// of its group-stage matches has locked. +// or an admin has flipped `predictionsClosed` on it; a group's 1°/2°/3° +// placement card locks only once *every* one of its group-stage matches +// has locked — so users can keep refining their placement picks as the +// matchdays unfold, right up until the final match of the group kicks off. function toDate(value) { if (!value) { @@ -51,23 +53,26 @@ function buildGroupCodeIndex(groups) { return codeToGroupId; } -// A group's prediction (1st/2nd/3rd) is locked as soon as any group-stage -// match between two of its teams has kicked off or been manually closed. -// Once any match has been played you have new information, so re-ranking -// would let you cheat. +// A group's 1°/2°/3° placement prediction stays open while at least one +// group-stage match between two of its teams is still editable. The group +// locks only once *every* such match has kicked off or been manually +// closed by an admin — i.e. when the final group match begins. A group +// with no scheduled group-stage matches in the payload stays open. function isGroupPredictionLocked({ groupId, groupStageMatches, codeToGroupId, now }) { const moment = toDate(now) || new Date(); + let sawMatchForGroup = false; for (const match of groupStageMatches || []) { const homeGroupId = match?.homeLabel ? codeToGroupId.get(match.homeLabel) : null; const awayGroupId = match?.awayLabel ? codeToGroupId.get(match.awayLabel) : null; if (homeGroupId !== groupId && awayGroupId !== groupId) { continue; } - if (isMatchPredictionLocked(match, moment)) { - return true; + sawMatchForGroup = true; + if (!isMatchPredictionLocked(match, moment)) { + return false; } } - return false; + return sawMatchForGroup; } function buildLockState({ rounds, groups, now } = {}) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0b4f4da..9b964a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -331,11 +331,18 @@ The rules in `api/locking.cjs`: - a knockout match locks the moment `match.matchDate <= now` - a match also locks if an admin has flipped `Match.predictionsClosed` to `true` via `PATCH /api/tournaments/:id/matches/:matchId` (admin-only) -- a group's 1°/2°/3° prediction locks as soon as any of its group-stage - matches between two of its teams has locked — by kickoff time or by an - admin closing one of them +- a group's 1°/2°/3° placement prediction stays editable while at least + one of its group-stage matches between two of its teams is still + unlocked; the group only locks once *every* such match has kicked off + or been manually closed by an admin, i.e. as the group's final match + begins. This is deliberately the latest possible per-match-safe + cutoff so users can keep refining their placement picks throughout + the group stage — by the time the last match kicks off, one fixture + is still uncertain, so placement remains a genuine prediction - a match with `matchDate = null` and `predictionsClosed = false` stays - open until the importer or admin acts on it + open until the importer or admin acts on it; a group that contains + such a match therefore also stays open until that match is scheduled + or closed `serializeRounds` and `serializeGroups` attach `predictionLocked: boolean` to every match and group in the API response so clients can render those diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md index bacfb1e..7703cac 100644 --- a/docs/QA_CHECKLIST.md +++ b/docs/QA_CHECKLIST.md @@ -180,18 +180,26 @@ Locking is strictly per match. There is no tournament-wide kill switch. 1. Set the `matchDate` on one knockout match to a timestamp in the past (database edit or via the importer). -2. Set the `matchDate` on one group-stage match to a timestamp in the past. -3. Reload the prediction wizard as a normal user. -4. Submit the wizard with deliberately changed picks for the locked match - and the locked group, plus changes to still-open matches and groups. +2. Set the `matchDate` on **one** group-stage match to a timestamp in + the past, leaving the rest of that group's matches in the future. +3. Set the `matchDate` on **every** group-stage match for a second + group to a timestamp in the past. +4. Reload the prediction wizard as a normal user. +5. Submit the wizard with deliberately changed picks for the locked + knockout match, both groups' 1°/2°/3° selectors, and a still-open + match. Expected: - locked knockout match renders read-only with a "kickoff started" pill -- locked group's 1°/2°/3° selectors are disabled with a "group locked" pill +- the first group (only one match started) stays editable — its + 1°/2°/3° selectors remain enabled and submitted changes persist +- the second group (every match has started) is locked: its 1°/2°/3° + selectors are disabled with a "group locked — last group match + kicked off" pill, and submitted changes do NOT overwrite the + existing rows in the DB - the random-fill button does not overwrite locked tiles in the UI -- after submit, the locked match's predicted winner is unchanged in the DB -- the locked group's 1°/2°/3° rows are unchanged in the DB +- after submit, the locked match's predicted winner is unchanged - unlocked matches and groups reflect the new picks - progression validation still runs against the merged set (locked upstream + new downstream picks) @@ -212,7 +220,8 @@ Expected: - the match renders read-only with a "kickoff started" pill even though the kickoff time has not passed - if the closed match is a group-stage match, the corresponding group's - 1°/2°/3° selectors also lock + 1°/2°/3° selectors only lock when *every* other group-stage match in + that group has also kicked off or been admin-closed - submitting the wizard does not change the closed match's stored pick - the admin "Reopen predictions" button restores the editable state and subsequent saves work normally diff --git a/src/i18n/messages/en.js b/src/i18n/messages/en.js index 44a3390..43d77c5 100644 --- a/src/i18n/messages/en.js +++ b/src/i18n/messages/en.js @@ -308,7 +308,7 @@ export const EN = { incompleteGroups: 'Please complete all required group placements', incompleteRounds: 'Please select winners for all matches', kickoffStarted: 'Kickoff started — picks locked', - groupLocked: 'Group locked — first match kicked off', + groupLocked: 'Group locked — last group match kicked off', actualWinner: 'Actual winner', actualStandings: 'Actual standings', duplicateGroupTeams: 'Each group placement must use different teams', diff --git a/src/i18n/messages/es.js b/src/i18n/messages/es.js index 7b618b8..d330193 100644 --- a/src/i18n/messages/es.js +++ b/src/i18n/messages/es.js @@ -308,7 +308,7 @@ export const ES = { incompleteGroups: 'Completa todas las posiciones requeridas de cada grupo', incompleteRounds: 'Selecciona ganadores para todos los enfrentamientos', kickoffStarted: 'Empezó el partido — pronóstico bloqueado', - groupLocked: 'Grupo bloqueado — ya arrancó el primer partido', + groupLocked: 'Grupo bloqueado — ya arrancó el último partido del grupo', actualWinner: 'Ganador real', actualStandings: 'Posiciones reales', duplicateGroupTeams: 'Cada posicion del grupo debe tener un equipo distinto', diff --git a/src/i18n/messages/it.js b/src/i18n/messages/it.js index 5cccd10..fa012ed 100644 --- a/src/i18n/messages/it.js +++ b/src/i18n/messages/it.js @@ -310,7 +310,7 @@ export const IT = { incompleteGroups: 'Completa tutti i posizionamenti richiesti nei gironi', incompleteRounds: 'Seleziona i vincitori per tutti gli incontri', kickoffStarted: 'Partita iniziata — pronostico bloccato', - groupLocked: 'Girone bloccato — la prima partita è iniziata', + groupLocked: 'Girone bloccato — è iniziata l\'ultima partita del girone', actualWinner: 'Vincitore reale', actualStandings: 'Classifica reale', duplicateGroupTeams: 'Ogni posizione nel girone deve usare squadre diverse', diff --git a/src/i18n/messages/nl.js b/src/i18n/messages/nl.js index f18dc01..e0eba6b 100644 --- a/src/i18n/messages/nl.js +++ b/src/i18n/messages/nl.js @@ -310,7 +310,7 @@ export const NL = { incompleteGroups: 'Vul alle vereiste groepsplaatsingen in', incompleteRounds: 'Selecteer winnaars voor alle wedstrijden', kickoffStarted: 'Wedstrijd begonnen — voorspelling vergrendeld', - groupLocked: 'Groep vergrendeld — eerste wedstrijd is begonnen', + groupLocked: 'Groep vergrendeld — laatste groepswedstrijd is begonnen', actualWinner: 'Werkelijke winnaar', actualStandings: 'Werkelijke stand', duplicateGroupTeams: 'Elke groepsplaatsing moet verschillende teams gebruiken', diff --git a/src/i18n/messages/pt.js b/src/i18n/messages/pt.js index 4309fab..a0c747a 100644 --- a/src/i18n/messages/pt.js +++ b/src/i18n/messages/pt.js @@ -310,7 +310,7 @@ export const PT = { incompleteGroups: 'Complete todas as posições obrigatórias de cada grupo', incompleteRounds: 'Selecione os vencedores de todos os confrontos', kickoffStarted: 'Jogo começou — palpite bloqueado', - groupLocked: 'Grupo bloqueado — o primeiro jogo já começou', + groupLocked: 'Grupo bloqueado — o último jogo do grupo já começou', actualWinner: 'Vencedor real', actualStandings: 'Classificação real', duplicateGroupTeams: 'Cada posição do grupo deve usar times diferentes', diff --git a/tests/locking.test.mjs b/tests/locking.test.mjs index 9141ad2..0bfa2f8 100644 --- a/tests/locking.test.mjs +++ b/tests/locking.test.mjs @@ -116,7 +116,7 @@ test('isMatchPredictionLocked uses kickoff time as the threshold', () => { ); }); -test('isGroupPredictionLocked locks a group as soon as any of its matches kicks off', () => { +test('isGroupPredictionLocked stays open while at least one group-stage match has not kicked off', () => { const { groups, rounds } = buildFixture(); const groupStageMatches = rounds[0].matches; const codeToGroupId = buildGroupCodeIndex(groups); @@ -128,8 +128,8 @@ test('isGroupPredictionLocked locks a group as soon as any of its matches kicks codeToGroupId, now: REFERENCE_NOW, }), - true, - 'group A is locked because MEX vs RSA has already been played' + false, + 'group A is still open because KOR vs CZE has not kicked off yet, even though MEX vs RSA was already played' ); assert.equal( isGroupPredictionLocked({ @@ -143,6 +143,43 @@ test('isGroupPredictionLocked locks a group as soon as any of its matches kicks ); }); +test('isGroupPredictionLocked locks a group only after every group-stage match in the group has kicked off', () => { + const { groups } = buildFixture(); + const codeToGroupId = buildGroupCodeIndex(groups); + const groupStageMatches = [ + { id: 'm1', homeLabel: 'MEX', awayLabel: 'RSA', matchDate: '2026-06-11T20:00:00Z' }, + { id: 'm2', homeLabel: 'KOR', awayLabel: 'CZE', matchDate: '2026-06-12T20:00:00Z' }, + { id: 'm3', homeLabel: 'MEX', awayLabel: 'KOR', matchDate: '2026-06-15T17:59:59Z' }, + ]; + + assert.equal( + isGroupPredictionLocked({ + groupId: 'group-a', + groupStageMatches, + codeToGroupId, + now: REFERENCE_NOW, + }), + true, + 'group A locks once every one of its group-stage matches has kicked off' + ); +}); + +test('isGroupPredictionLocked stays open when the group has no group-stage matches in the payload', () => { + const groups = [{ id: 'group-x', teams: [{ id: 't-xyz', code: 'XYZ' }] }]; + const codeToGroupId = buildGroupCodeIndex(groups); + + assert.equal( + isGroupPredictionLocked({ + groupId: 'group-x', + groupStageMatches: [], + codeToGroupId, + now: REFERENCE_NOW, + }), + false, + 'a group with no group-stage matches in the payload is treated as still open' + ); +}); + test('isGroupPredictionLocked ignores matches whose labels are not group team codes', () => { const groups = [ { @@ -181,10 +218,36 @@ test('buildLockState collects all locked match and group ids', () => { ['m-gs-played', 'm-r32-played'], 'should lock the past kickoffs and only those' ); + assert.deepEqual( + [...state.lockedGroupIds].sort(), + [], + 'neither group locks yet: each still has at least one group-stage match that has not kicked off' + ); +}); + +test('buildLockState locks a group once every one of its group-stage matches has kicked off', () => { + const { groups } = buildFixture(); + const rounds = [ + { + id: 'round-gs', + name: 'group_stage', + matches: [ + { id: 'm-a-1', homeLabel: 'MEX', awayLabel: 'RSA', matchDate: '2026-06-11T20:00:00Z' }, + { id: 'm-a-2', homeLabel: 'KOR', awayLabel: 'CZE', matchDate: '2026-06-12T20:00:00Z' }, + { id: 'm-a-3', homeLabel: 'MEX', awayLabel: 'KOR', matchDate: '2026-06-15T17:59:59Z' }, + // Group B still has a future match. + { id: 'm-b-1', homeLabel: 'CAN', awayLabel: 'BIH', matchDate: '2026-06-11T20:00:00Z' }, + { id: 'm-b-2', homeLabel: 'QAT', awayLabel: 'SUI', matchDate: '2026-06-30T20:00:00Z' }, + ], + }, + ]; + + const state = buildLockState({ rounds, groups, now: REFERENCE_NOW }); + assert.deepEqual( [...state.lockedGroupIds].sort(), ['group-a'], - 'should lock group A because MEX vs RSA has been played' + 'only group A locks because all of its group-stage matches have started' ); }); @@ -222,7 +285,7 @@ test('isMatchPredictionLocked treats predictionsClosed=true as locked regardless ); }); -test('isGroupPredictionLocked locks a group when a group-stage match is manually closed', () => { +test('isGroupPredictionLocked locks a group when every group-stage match is manually closed', () => { const groups = [ { id: 'group-c', @@ -232,7 +295,9 @@ test('isGroupPredictionLocked locks a group when a group-stage match is manually ], }, ]; - const groupStageMatches = [ + const codeToGroupId = buildGroupCodeIndex(groups); + + const allClosed = [ { id: 'm-future-closed', homeLabel: 'ARG', @@ -241,16 +306,41 @@ test('isGroupPredictionLocked locks a group when a group-stage match is manually predictionsClosed: true, }, ]; - const codeToGroupId = buildGroupCodeIndex(groups); - assert.equal( isGroupPredictionLocked({ groupId: 'group-c', - groupStageMatches, + groupStageMatches: allClosed, codeToGroupId, now: REFERENCE_NOW, }), true, - 'closing a single group-stage match locks the whole group' + 'closing the only group-stage match for the group locks the group' + ); + + const mixed = [ + { + id: 'm-future-closed', + homeLabel: 'ARG', + awayLabel: 'AUS', + matchDate: '2027-01-01T00:00:00Z', + predictionsClosed: true, + }, + { + id: 'm-future-open', + homeLabel: 'ARG', + awayLabel: 'AUS', + matchDate: '2027-01-02T00:00:00Z', + predictionsClosed: false, + }, + ]; + assert.equal( + isGroupPredictionLocked({ + groupId: 'group-c', + groupStageMatches: mixed, + codeToGroupId, + now: REFERENCE_NOW, + }), + false, + 'closing only one of two group-stage matches does NOT lock the group' ); });