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
49 changes: 41 additions & 8 deletions apps/web/components/bracket/BracketBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,23 @@ function migrateBracket(b: Bracket | null): { bracket: Bracket | null; wiped: bo
/**
* Count picks for a given knockout stage so the per-tab progress
* indicator reads "x of N picked".
*
* Tim 2026-06-05: previously this counted any cascaded knockout that
* had a stored pick, including matches where one slot was still TBD
* (e.g. a Best-3rd opponent that hadn't been resolved because the
* user only filled in 6 of 8 thirds). The engine keeps the user's
* stored pick for those half-resolved matches and only nulls it out
* when both slots resolve to teams that exclude it -- which inflated
* the counter. We now require both slots to be resolved AND the
* engine-computed `effective_winner` to be non-null. That handles
* three categories of stored-but-not-actually-picked state in one
* check: (a) only one slot resolved, (b) zero slots resolved, and
* (c) both resolved but the pick references a team no longer in the
* matchup (engine sets effective_winner=null + emits a warning).
*/
function knockoutCountFor(
stage: TabId,
cascaded: CascadedBracket,
picks: Record<string, MatchPrediction>,
): { picked: number; total: number } {
const stageIds =
stage === "sf"
Expand All @@ -246,7 +258,15 @@ function knockoutCountFor(
);
const total = matches.length;
let picked = 0;
for (const m of matches) if (picks[m.id]) picked += 1;
for (const m of matches) {
if (
m.home.team !== null &&
m.away.team !== null &&
m.effective_winner !== null
) {
picked += 1;
}
}
return { picked, total };
}

Expand Down Expand Up @@ -1354,7 +1374,20 @@ export function BracketBuilder(props: BracketBuilderProps) {

const totalGroupMatches = tournament.group_fixtures.length;
const completedGroupMatches = Object.keys(bracket.matchPredictions).length;
const completedKnockouts = Object.keys(bracket.knockoutPredictions).length;
// See `knockoutCountFor` above: a knockout match counts as "picked"
// only when both slots have resolved teams AND the engine has a
// valid effective_winner. Tim 2026-06-05 caught the header reading
// 104/104 with only 6 of 8 thirds picked, because the previous
// count was `Object.keys(bracket.knockoutPredictions).length` which
// includes picks for matches whose other side is still TBD.
const completedKnockouts = cascaded.knockouts.reduce(
(n, k) =>
n +
(k.home.team !== null && k.away.team !== null && k.effective_winner !== null
? 1
: 0),
0,
);
const totalKnockouts = tournament.knockouts.length;
const totalPicks = totalGroupMatches + totalKnockouts;
const totalCompleted = completedGroupMatches + completedKnockouts;
Expand Down Expand Up @@ -1382,11 +1415,11 @@ export function BracketBuilder(props: BracketBuilderProps) {
picked: (bracket.bestThirds ?? []).length,
total: 8,
};
const r32Progress = knockoutCountFor("r32", cascaded, bracket.knockoutPredictions);
const r16Progress = knockoutCountFor("r16", cascaded, bracket.knockoutPredictions);
const qfProgress = knockoutCountFor("qf", cascaded, bracket.knockoutPredictions);
const sfProgress = knockoutCountFor("sf", cascaded, bracket.knockoutPredictions);
const finalProgress = knockoutCountFor("final", cascaded, bracket.knockoutPredictions);
const r32Progress = knockoutCountFor("r32", cascaded);
const r16Progress = knockoutCountFor("r16", cascaded);
const qfProgress = knockoutCountFor("qf", cascaded);
const sfProgress = knockoutCountFor("sf", cascaded);
const finalProgress = knockoutCountFor("final", cascaded);

const progressByTab: Record<TabId, { picked: number; total: number }> = {
groups: groupProgress,
Expand Down
23 changes: 18 additions & 5 deletions apps/web/components/bracket/LockSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,24 @@ export function LockSummary(props: LockSummaryProps) {
const deadline = Date.parse(deadline_utc);

// Per-match counts (group + knockout = up to 104 for World Cup 2026).
// Tim 2026-06-05: knockout picks are counted off the cascaded view
// (both slots resolved AND engine has a valid effective_winner), not
// off the raw bracket.knockoutPredictions map. The raw map keeps
// stored picks for matches whose other side is still TBD (e.g. a
// Best-3rd opponent the user hasn't picked), and those should not
// count toward "X of 104 picks saved".
const totalGroup = tournament.group_fixtures.length;
const totalKnockout = tournament.knockouts.length;
const totalPicks = totalGroup + totalKnockout;
const groupPicks = Object.keys(bracket.matchPredictions).length;
const knockoutPicks = Object.keys(bracket.knockoutPredictions).length;
const knockoutPicks = cascaded.knockouts.reduce(
(n, k) =>
n +
(k.home.team !== null && k.away.team !== null && k.effective_winner !== null
? 1
: 0),
0,
);
const committed = groupPicks + knockoutPicks;

// Predicted champion: cascade's effective_winner of the Final.
Expand All @@ -95,10 +108,10 @@ export function LockSummary(props: LockSummaryProps) {
// synthetic stub that PR #140 generated before the backend lookup
// existed.
const shareWinner = champion === "—" ? "TBD" : champion;
const isComplete =
Object.keys(bracket.matchPredictions).length +
Object.keys(bracket.knockoutPredictions).length >=
totalPicks;
// Same cascade-aware semantics as `committed` above: the bracket is
// complete only when every group AND every knockout match has a
// genuine pick that the engine accepts.
const isComplete = committed >= totalPicks;
const [storedShareGuid, setStoredShareGuid] = useState<string | null>(null);
useEffect(() => {
setStoredShareGuid(loadStoredShareGuid(tournament.id, localUserId()));
Expand Down
14 changes: 13 additions & 1 deletion apps/web/components/molecule/MoleculeScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -838,7 +838,19 @@ export function MoleculeScene({
// bracket and the viewer can't go fill it in).
if (readOnly) return null;
const groupPicks = Object.keys(bracket.matchPredictions).length;
const knockoutPicks = Object.keys(bracket.knockoutPredictions).length;
// Tim 2026-06-05: cascade-aware knockout count. The raw
// predictions map keeps picks for matches with one TBD side,
// so it overstated "remaining" -- e.g. with 6 of 8 thirds
// picked the modal would have read "0 remaining" but the
// bracket isn't actually finishable until the thirds are in.
const knockoutPicks = cascaded.knockouts.reduce(
(n, k) =>
n +
(k.home.team !== null && k.away.team !== null && k.effective_winner !== null
? 1
: 0),
0,
);
const totalPicks = groupPicks + knockoutPicks;
const totalRequired =
tournament.group_fixtures.length + tournament.knockouts.length;
Expand Down
17 changes: 16 additions & 1 deletion apps/web/components/share/ShareSavePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,22 @@ export function ShareSavePage({
const totalKnockout = tournament.knockouts.length;
const totalPicks = totalGroup + totalKnockout;
const groupPicks = bracket ? Object.keys(bracket.matchPredictions).length : 0;
const knockoutPicks = bracket ? Object.keys(bracket.knockoutPredictions).length : 0;
// Tim 2026-06-05: same cascade-aware semantics as BracketBuilder /
// LockSummary. The raw `bracket.knockoutPredictions` map keeps picks
// for matches whose other side is still TBD, so counting its keys
// inflated the "X of 104" readout (104/104 with only 6 of 8 thirds
// picked). A knockout match counts as picked only when both slots
// are resolved AND the engine accepted a winner.
const knockoutPicks = cascaded
? cascaded.knockouts.reduce(
(n, k) =>
n +
(k.home.team !== null && k.away.team !== null && k.effective_winner !== null
? 1
: 0),
0,
)
: 0;
const committed = groupPicks + knockoutPicks;
const isComplete = committed === totalPicks;

Expand Down
Loading