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' ); });