Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions api/locking.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 } = {}) {
Expand Down
15 changes: 11 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions docs/QA_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/messages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/messages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/messages/it.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/messages/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/messages/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
110 changes: 100 additions & 10 deletions tests/locking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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({
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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'
);
});

Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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'
);
});
Loading