// bracket.js — knockout bracket. R32 slots come from bracket-config.json
// (group positions resolved via computeStandings(), best-third slots via
// thirdPlaceAssignment); every later round is generated by sequential pairing
// of winners (nodes 0-1 → next 0, 2-3 → next 1, …). SF losers feed the
// third-place match. Unresolvable slots render as placeholders, unplayed
// matches as TBD. Interactions land in step 8, simulation in step 9.
import { getData, flagSrc } from './app.js';
import { get as storageGet, set as storageSet, getFavorites } from './storage.js';
import { computeStandings, isGroupFinished } from './groups.js';
import { t, translatePhase } from './i18n.js';
import { openMatchModal } from './modal.js';
const ROUNDS = [
{ id: 'R32', phase: 'Round of 32', size: 16 },
{ id: 'R16', phase: 'Round of 16', size: 8 },
{ id: 'QF', phase: 'Quarterfinals', size: 4 },
{ id: 'SF', phase: 'Semifinals', size: 2 },
{ id: 'FINAL', phase: 'Final', size: 1 },
];
// ---------------------------------------------------------------- tree
let cachedTree = null;
// results are static per page load; simulation (step 9) will call this
export function invalidateBracket() {
cachedTree = null;
}
export function getBracketTree() {
if (!cachedTree) cachedTree = buildTree();
return cachedTree;
}
function buildTree() {
const { matches, resultByMatchId, bracketConfig } = getData();
const simulation = storageGet('simulation', {});
const standings = computeStandings();
const matchByRef = new Map(matches.filter((m) => m.bracketRef).map((m) => [m.bracketRef, m]));
const nodesByRef = new Map();
const rounds = [];
let previous = null;
for (const round of ROUNDS) {
const nodes = [];
for (let i = 0; i < round.size; i += 1) {
const ref = round.id === 'FINAL' ? 'FINAL' : `${round.id}-${i + 1}`;
const node = {
ref,
round: round.id,
phase: round.phase,
match: matchByRef.get(ref) ?? null,
home: null,
away: null,
winner: null,
loser: null,
result: null,
};
if (round.id === 'R32') {
const entry = bracketConfig.round32[i];
node.home = configSlot(entry.home, standings, bracketConfig);
node.away = configSlot(entry.away, standings, bracketConfig);
} else {
node.home = previous[i * 2].winner ? { teamId: previous[i * 2].winner } : { ph: { kind: 'tbd' } };
node.away = previous[i * 2 + 1].winner ? { teamId: previous[i * 2 + 1].winner } : { ph: { kind: 'tbd' } };
}
decide(node, resultByMatchId);
applySimulation(node, simulation);
nodes.push(node);
nodesByRef.set(ref, node);
}
rounds.push({ ...round, nodes });
previous = nodes;
}
const [sf1, sf2] = rounds.find((r) => r.id === 'SF').nodes;
const third = {
ref: 'THIRD-PLACE',
round: 'THIRD',
phase: 'Third Place',
match: matchByRef.get('THIRD-PLACE') ?? null,
home: sf1.loser ? { teamId: sf1.loser } : { ph: { kind: 'tbd' } },
away: sf2.loser ? { teamId: sf2.loser } : { ph: { kind: 'tbd' } },
winner: null,
loser: null,
result: null,
};
decide(third, resultByMatchId);
applySimulation(third, simulation);
nodesByRef.set('THIRD-PLACE', third);
return { rounds, third, nodesByRef, champion: nodesByRef.get('FINAL').winner };
}
function configSlot(spec, standings, config) {
if (spec.type === 'third') {
const group = config.thirdPlaceAssignment?.[String(spec.slot)];
if (group && isGroupFinished(group)) return { teamId: standings[group][2].teamId };
return { ph: { kind: 'third', n: spec.slot } };
}
if (isGroupFinished(spec.ref)) return { teamId: standings[spec.ref][spec.pos - 1].teamId };
return { ph: { kind: spec.pos === 1 ? 'groupWinner' : 'groupRunnerUp', g: spec.ref } };
}
// real finished results only — the simulation overlay is applySimulation()'s
// job and always loses to a real result
function decide(node, resultByMatchId) {
const result = node.match ? resultByMatchId.get(node.match.id) : null;
node.result = result ?? null;
node.simulated = false;
node.simScore = null;
if (!result || result.status !== 'finished' || !node.home.teamId || !node.away.teamId) return;
let side = result.homeScore > result.awayScore ? 'home'
: result.homeScore < result.awayScore ? 'away'
: null;
if (!side && result.penalties) {
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
}
if (!side) return;
node.winner = node[side].teamId;
node.loser = node[side === 'home' ? 'away' : 'home'].teamId;
}
function isSimulatable(node) {
const realLocked = node.result && node.result.status !== 'scheduled';
return Boolean(node.match) && !realLocked && Boolean(node.home.teamId) && Boolean(node.away.teamId);
}
// User simulation overlays matches without a real final result. Entries whose
// winner no longer matches either resolved team (stale picks after upstream
// changes) are silently ignored — same validation the prediction import uses.
function applySimulation(node, simulation) {
if (node.winner || !node.home.teamId || !node.away.teamId) return;
if (node.result?.status === 'finished') return;
const entry = simulation[node.ref];
if (!entry || (entry.winner !== node.home.teamId && entry.winner !== node.away.teamId)) return;
node.winner = entry.winner;
node.loser = entry.winner === node.home.teamId ? node.away.teamId : node.home.teamId;
node.simulated = true;
const parsed = /^(\d+)-(\d+)$/.exec(entry.score ?? '');
node.simScore = parsed ? { home: Number(parsed[1]), away: Number(parsed[2]) } : null;
}
// ------------------------------------------------------ display helpers
function slotDisplay(slot) {
if (slot?.teamId) {
const team = getData().teamById.get(slot.teamId);
return { team, label: team.name };
}
const ph = slot?.ph ?? { kind: 'tbd' };
switch (ph.kind) {
case 'groupWinner': return { team: null, label: t('bracket.groupWinner').replace('{g}', ph.g) };
case 'groupRunnerUp': return { team: null, label: t('bracket.groupRunnerUp').replace('{g}', ph.g) };
case 'third': return { team: null, label: t('bracket.bestThird').replace('{n}', ph.n) };
default: return { team: null, label: t('app.tbd') };
}
}
// Shared by schedule.js and modal.js: given a match (group or knockout) or a
// bracketRef string, returns { home, away } as { team: Team|null, label }.
export function resolveBracketTeams(matchOrRef) {
const ref = typeof matchOrRef === 'string' ? matchOrRef : matchOrRef.bracketRef;
if (!ref) {
const { teamById } = getData();
const make = (id) => {
const team = teamById.get(id) ?? null;
return { team, label: team?.name ?? t('app.tbd') };
};
return { home: make(matchOrRef.homeTeam), away: make(matchOrRef.awayTeam) };
}
const node = getBracketTree().nodesByRef.get(ref);
if (!node) {
const tbd = { team: null, label: t('app.tbd') };
return { home: tbd, away: tbd };
}
return { home: slotDisplay(node.home), away: slotDisplay(node.away) };
}
// ------------------------------------------------ shared favorites helper
// Matches involving a favorited team — shared by schedule.js and bracket.js.
// Uses resolved slots so knockout matches count once their teams are known.
export function getFavoriteMatches(matches, favorites) {
if (!favorites.length) return [];
const favSet = new Set(favorites);
return matches.filter((match) => {
const slots = resolveBracketTeams(match);
return favSet.has(slots.home.team?.id) || favSet.has(slots.away.team?.id);
});
}
// --------------------------------------------------- challenge (step 12)
// For every knockout match with a real finished result, compares the actual
// winner with the user's simulated pick. Recalculated on every render — no
// persistence of its own.
export function calculateChallengeScore(simulation, results, bracketTree) {
const resultById = new Map(results.map((r) => [r.matchId, r]));
const byPhaseCount = {};
let correct = 0;
let total = 0;
for (const node of bracketTree.nodesByRef.values()) {
if (!node.match || !node.home.teamId || !node.away.teamId) continue;
const result = resultById.get(node.match.id);
if (result?.status !== 'finished') continue;
let side = result.homeScore > result.awayScore ? 'home'
: result.homeScore < result.awayScore ? 'away'
: null;
if (!side && result.penalties) {
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
}
if (!side) continue;
const realWinner = node[side].teamId;
total += 1;
byPhaseCount[node.phase] ??= { correct: 0, total: 0 };
byPhaseCount[node.phase].total += 1;
if (simulation[node.ref]?.winner === realWinner) {
correct += 1;
byPhaseCount[node.phase].correct += 1;
}
}
const byPhase = Object.fromEntries(
Object.entries(byPhaseCount).map(([phase, c]) => [phase, `${c.correct}/${c.total}`]),
);
return { correct, total, byPhase };
}
// ------------------------------------------- share prediction (step 12)
export function getShareableLink() {
const url = new URL(location.href);
url.searchParams.set('prediction', btoa(JSON.stringify(getSimulation())));
return url.toString();
}
// Reads ?prediction= on load. Applies only if every key exists in the current
// bracket tree and the user confirms; invalid/incompatible data is silently
// ignored. The param is removed from the URL either way.
export function loadPredictionFromURL() {
const param = new URLSearchParams(location.search).get('prediction');
if (!param) return;
const cleanURL = new URL(location.href);
cleanURL.searchParams.delete('prediction');
history.replaceState(null, '', cleanURL);
try {
const data = JSON.parse(atob(param));
if (typeof data !== 'object' || data === null || Array.isArray(data)) return;
const entries = Object.entries(data);
if (!entries.length) return;
const tree = getBracketTree();
const valid = entries.every(([ref, entry]) =>
tree.nodesByRef.has(ref) && entry && typeof entry.winner === 'string');
if (!valid) return;
if (window.confirm(t('share.confirm'))) {
storageSet('simulation', data);
refreshAfterSimChange();
}
} catch {
// malformed base64/JSON — ignore silently per spec
}
}
// -------------------------------------------------------------- render
export function initBracket() {
render();
document.addEventListener('langchange', render);
document.addEventListener('favchange', render);
loadPredictionFromURL();
const root = document.getElementById('bracket-root');
const activate = (el) => {
if (simMode) {
const node = getBracketTree().nodesByRef.get(el.dataset.ref);
if (node && isSimulatable(node)) {
openSimEditor(el.dataset.ref);
return;
}
}
openMatchModal(Number(el.dataset.matchId));
};
root.addEventListener('click', (event) => {
const node = event.target.closest('[data-match-id]');
if (node) activate(node);
});
root.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const node = event.target.closest('[data-match-id]');
if (node) {
event.preventDefault();
activate(node);
}
});
}
function challengeCardHTML(challenge) {
const phases = Object.entries(challenge.byPhase)
.map(([phase, score]) => `${translatePhase(phase)}: ${score}`)
.join('');
const summary = t('challenge.correct')
.replace('{x}', challenge.correct)
.replace('{y}', challenge.total);
return `
${t('challenge.title')}
${summary}
${phases}
`;
}
function render() {
const tree = getBracketTree();
const simulation = getSimulation();
const challenge = calculateChallengeScore(simulation, getData().results, tree);
const hasPicks = Object.keys(simulation).length > 0;
document.getElementById('bracket-root').innerHTML = `
${challenge.total ? challengeCardHTML(challenge) : ''}
${simMode ? `${t('sim.hint')}
` : ''}
${tree.rounds.filter((r) => r.id !== 'FINAL').map(roundColumnHTML).join('')}
${finalColumnHTML(tree)}
`;
document.getElementById('sim-toggle').addEventListener('click', () => {
simMode = !simMode;
render();
});
document.getElementById('sim-reset').addEventListener('click', () => {
storageSet('simulation', {});
refreshAfterSimChange();
});
document.getElementById('share-pred').addEventListener('click', async (event) => {
const btn = event.currentTarget;
const link = getShareableLink();
try {
await navigator.clipboard.writeText(link);
const original = btn.textContent;
btn.textContent = t('share.copied');
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually
}
});
initInteractions();
}
function roundColumnHTML(round) {
const pairs = [];
for (let i = 0; i < round.nodes.length; i += 2) {
pairs.push(`
${matchNodeHTML(round.nodes[i])}
${matchNodeHTML(round.nodes[i + 1])}
`);
}
return `
${translatePhase(round.phase)}
${pairs.join('')}
`;
}
function finalColumnHTML(tree) {
const finalNode = tree.rounds.find((r) => r.id === 'FINAL').nodes[0];
const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
return `
${translatePhase('Final')}
🏆
${t('bracket.champion')}
${champion ? champion.label : t('app.tbd')}
${matchNodeHTML(finalNode)}
${translatePhase('Third Place')}
${matchNodeHTML(tree.third)}
`;
}
function matchNodeHTML(node) {
const home = slotDisplay(node.home);
const away = slotDisplay(node.away);
const live = node.result?.status === 'live';
const favorites = getFavorites();
const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId);
const classes = [
'bracket-match',
live ? 'is-live' : '',
node.simulated ? 'is-sim' : '',
hasFav ? 'has-fav' : '',
simMode && isSimulatable(node) ? 'simulatable' : '',
].filter(Boolean).join(' ');
const interactive = node.match
? `data-match-id="${node.match.id}" tabindex="0" role="button"
aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"`
: '';
return `
${node.simulated ? `${t('sim.chip')}` : ''}
${teamRowHTML(home, node, 'home')}
${teamRowHTML(away, node, 'away')}
`;
}
// ----------------------------------------------------- simulation (step 9)
let simMode = false;
function getSimulation() {
return storageGet('simulation', {});
}
function refreshAfterSimChange() {
invalidateBracket();
render();
document.dispatchEvent(new CustomEvent('simchange'));
}
function ensureSimDialog() {
if (document.getElementById('sim-dialog')) return;
document.getElementById('modal-root').insertAdjacentHTML(
'beforeend',
'',
);
const dialog = document.getElementById('sim-dialog');
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
}
function openSimEditor(ref) {
ensureSimDialog();
const dialog = document.getElementById('sim-dialog');
const node = getBracketTree().nodesByRef.get(ref);
const existing = getSimulation()[ref] ?? null;
const home = slotDisplay(node.home);
const away = slotDisplay(node.away);
const [scoreHome, scoreAway] = (existing?.score ?? '').split('-');
let selected = existing?.winner ?? null;
const teamButton = (slot, display) => `
`;
dialog.innerHTML = `
`;
const saveBtn = dialog.querySelector('#sim-save');
const homeInput = dialog.querySelector('#sim-score-home');
const awayInput = dialog.querySelector('#sim-score-away');
const select = (teamId) => {
selected = teamId;
for (const btn of dialog.querySelectorAll('.sim-team')) {
btn.classList.toggle('active', btn.dataset.team === teamId);
}
saveBtn.disabled = !selected;
};
for (const btn of dialog.querySelectorAll('.sim-team')) {
btn.addEventListener('click', () => select(btn.dataset.team));
}
// an unequal score implies the winner; a draw needs an explicit pick (pens)
const onScoreInput = () => {
const h = homeInput.value === '' ? null : Number(homeInput.value);
const a = awayInput.value === '' ? null : Number(awayInput.value);
if (h === null || a === null || h === a) return;
select(h > a ? node.home.teamId : node.away.teamId);
};
homeInput.addEventListener('input', onScoreInput);
awayInput.addEventListener('input', onScoreInput);
dialog.setAttribute('aria-label', `${t('sim.title')} — ${home.label} ${t('hero.vs')} ${away.label}`);
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
dialog.querySelector('#sim-clear').addEventListener('click', () => {
const simulation = getSimulation();
delete simulation[ref];
storageSet('simulation', simulation);
dialog.close();
refreshAfterSimChange();
});
saveBtn.addEventListener('click', () => {
if (!selected) return;
const h = homeInput.value === '' ? (selected === node.home.teamId ? 1 : 0) : Number(homeInput.value);
const a = awayInput.value === '' ? (selected === node.away.teamId ? 1 : 0) : Number(awayInput.value);
const simulation = getSimulation();
simulation[ref] = { winner: selected, score: `${h}-${a}` };
storageSet('simulation', simulation);
dialog.close();
refreshAfterSimChange();
});
dialog.showModal();
}
// ------------------------------------------------- interactions (step 8)
const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL'];
// zoom level survives re-renders (language switch) but not reloads
const view = { scale: 1, natW: 0, natH: 0 };
const MIN_SCALE = 0.4;
const MAX_SCALE = 2;
function initInteractions() {
const wrap = document.getElementById('bracket-wrap');
const zoomBox = document.getElementById('bracket-zoom');
const canvas = document.getElementById('bracket-canvas');
view.natW = 0; // re-measure lazily — panel may be hidden right now (offsetWidth 0)
if (view.scale !== 1) applyScale(wrap, zoomBox, canvas);
updateZoomLabel();
const ensureMeasured = () => {
if (view.natW) return true;
if (!canvas.offsetWidth) return false;
view.natW = canvas.offsetWidth;
view.natH = canvas.offsetHeight;
return true;
};
const setScale = (next, cx, cy) => {
if (!ensureMeasured()) return;
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next));
if (scale === view.scale) return;
const rect = wrap.getBoundingClientRect();
const px = (cx - rect.left + wrap.scrollLeft) / view.scale;
const py = (cy - rect.top + wrap.scrollTop) / view.scale;
view.scale = scale;
applyScale(wrap, zoomBox, canvas);
wrap.scrollLeft = px * scale - (cx - rect.left);
wrap.scrollTop = py * scale - (cy - rect.top);
updateZoomLabel();
};
const zoomAtCenter = (factor) => {
const rect = wrap.getBoundingClientRect();
setScale(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
};
document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25));
document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25));
document.getElementById('zoom-reset').addEventListener('click', () => {
if (!ensureMeasured()) return;
view.scale = 1;
applyScale(wrap, zoomBox, canvas);
updateZoomLabel();
});
wrap.addEventListener('wheel', (event) => {
event.preventDefault();
setScale(view.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY);
}, { passive: false });
// drag to pan (1 pointer) + pinch to zoom (2 pointers)
const pointers = new Map();
let dragging = false;
let dragMoved = false;
let start = null;
let pinch = null;
wrap.addEventListener('pointerdown', (event) => {
pointers.set(event.pointerId, event);
// IMPORTANT: do NOT setPointerCapture here — capturing retargets the
// eventual click to the wrap, which kills match-node clicks (modal and
// simulation). Capture only once a real drag/pinch begins.
if (pointers.size === 1) {
dragging = true;
dragMoved = false;
start = { x: event.clientX, y: event.clientY, left: wrap.scrollLeft, top: wrap.scrollTop };
} else if (pointers.size === 2) {
dragging = false;
for (const p of pointers.values()) {
try { wrap.setPointerCapture(p.pointerId); } catch { /* pointer already gone */ }
}
const [a, b] = [...pointers.values()];
pinch = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: view.scale };
}
});
wrap.addEventListener('pointermove', (event) => {
if (!pointers.has(event.pointerId)) return;
pointers.set(event.pointerId, event);
if (pointers.size === 2 && pinch) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
setScale(pinch.scale * (dist / pinch.dist), (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
} else if (dragging) {
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (!dragMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
dragMoved = true;
try { wrap.setPointerCapture(event.pointerId); } catch { /* pointer already gone */ }
wrap.classList.add('dragging');
}
if (dragMoved) {
wrap.scrollLeft = start.left - dx;
wrap.scrollTop = start.top - dy;
}
}
});
const endPointer = (event) => {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinch = null;
if (pointers.size === 0) {
dragging = false;
wrap.classList.remove('dragging');
}
};
wrap.addEventListener('pointerup', endPointer);
wrap.addEventListener('pointercancel', endPointer);
// a real drag must not fire the match-node click underneath
wrap.addEventListener('click', (event) => {
if (dragMoved) {
event.stopPropagation();
event.preventDefault();
dragMoved = false;
}
}, true);
// hover/focus path highlight
canvas.addEventListener('mouseover', (event) => {
const node = event.target.closest('.bracket-match');
if (node) showPath(node.dataset.ref);
});
canvas.addEventListener('mouseout', () => clearPath());
canvas.addEventListener('focusin', (event) => {
const node = event.target.closest('.bracket-match');
if (node) showPath(node.dataset.ref);
});
canvas.addEventListener('focusout', () => clearPath());
}
function applyScale(wrap, zoomBox, canvas) {
if (view.natW) {
zoomBox.style.width = `${view.natW * view.scale}px`;
zoomBox.style.height = `${view.natH * view.scale}px`;
}
canvas.style.transform = view.scale === 1 ? '' : `scale(${view.scale})`;
if (view.scale === 1) {
zoomBox.style.width = '';
zoomBox.style.height = '';
}
}
function updateZoomLabel() {
const label = document.getElementById('zoom-reset');
if (label) label.textContent = `${Math.round(view.scale * 100)}%`;
}
// ----------------------------------------------------- path highlight
function refOf(roundIndex, nodeIndex) {
const id = ROUND_ORDER[roundIndex];
return id === 'FINAL' ? 'FINAL' : `${id}-${nodeIndex + 1}`;
}
// full path = the hovered node, every feeder below it, and its winner's
// route up to the final
function pathRefs(ref) {
const refs = new Set([ref]);
if (ref === 'THIRD-PLACE') {
refs.add('SF-1');
refs.add('SF-2');
return refs;
}
const [roundId, num] = ref === 'FINAL' ? ['FINAL', '1'] : ref.split('-');
const r = ROUND_ORDER.indexOf(roundId);
if (r < 0) return refs;
let i = Number(num) - 1;
for (let rr = r, ii = i; rr < ROUND_ORDER.length - 1; rr += 1) {
ii = Math.floor(ii / 2);
refs.add(refOf(rr + 1, ii));
}
const stack = [[r, i]];
while (stack.length) {
const [cr, ci] = stack.pop();
if (cr === 0) continue;
for (const child of [ci * 2, ci * 2 + 1]) {
refs.add(refOf(cr - 1, child));
stack.push([cr - 1, child]);
}
}
return refs;
}
function showPath(ref) {
const canvas = document.getElementById('bracket-canvas');
clearPath();
canvas.classList.add('has-path');
for (const r of pathRefs(ref)) {
const el = canvas.querySelector(`[data-ref="${r}"]`);
if (!el) continue;
el.classList.add('path-on');
el.closest('.bracket-slot')?.classList.add('path-on');
el.closest('.bracket-pair')?.classList.add('path-on');
}
}
function clearPath() {
const canvas = document.getElementById('bracket-canvas');
canvas.classList.remove('has-path');
for (const el of canvas.querySelectorAll('.path-on')) el.classList.remove('path-on');
}
function teamRowHTML(display, node, side) {
const { result } = node;
let score = '';
if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) {
const goals = side === 'home' ? result.homeScore : result.awayScore;
const pens = result.penalties
? `(${side === 'home' ? result.penalties.home : result.penalties.away})`
: '';
score = `${goals}${pens}`;
} else if (node.simulated && node.simScore) {
score = `${node.simScore[side]}`;
}
const isWinner = node.winner !== null && node[side].teamId === node.winner;
const flag = display.team
? `
`
: '';
return `
${flag}
${display.label}
${score}
`;
}