feat(hero): display simultaneous group-final matches with shared timer

findFeaturedMatch → findFeaturedMatches: returns all matches sharing the
earliest kickoff, enabling the hero to show 2+ simultaneous group-final games
(same phase, shared time/countdown). renderHero splits single-match (unchanged
DOM) vs multi-match (stacked with dividers). heroMatchupHTML extracted for
reusable matchup layout.

CSS: .hero-matchups/match/divider/time for vertical stacking and shared time.
i18n: hero.nextMatches (EN/PT) for multi-match label.
This commit is contained in:
Lucas Kalil 2026-06-15 14:52:10 -03:00
parent 99ea02a604
commit 6e33142c96
5 changed files with 107 additions and 31 deletions

View file

@ -37,8 +37,9 @@ worldcup2026/
│ ├── js/
│ │ ├── app.js ★ Entry point: loadData() (Promise.all over data/),
│ │ │ tab routing + lastTab, formatMatchTime(), dashboard,
│ │ │ clock-driven hero (matchState/findFeaturedMatch +
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h match window)
│ │ │ clock-driven hero (matchState/findFeaturedMatches +
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h window; stacks
│ │ │ simultaneous group-final matches, one shared timer)
│ │ ├── schedule.js Match list, filters (incl. occurrence toggle
│ │ │ Played/Upcoming via hybrid matchState), search,
│ │ │ sort, "My Matches"; 60s clock-tick re-render

View file

@ -205,6 +205,14 @@ Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) —
- **`renderList`/`matchesFilters`/`matchCardHTML` agora recebem `now`** (um único `Date.now()` por render, consistente entre filtro e chips). Chaves i18n novas: `schedule.occAria/occAll/occPlayed/occUpcoming` + `status.pending` (EN/PT).
- **Verificado (preview, relógio fakeado via `Date.now` override + dispatch `timemodechange`):** Todos 104 / Já ocorreram 12 / A ocorrer 92; com `now=2026-06-16T03:00Z` → 4 chips "Pendente de resultado", Já ocorreram=16 / A ocorrer=88; Clear reseta pra Todos/104; rótulos EN ("All matches/Played/Upcoming") sobrevivem ao langchange; console limpo. Screenshot do estado "Já ocorreram" conferido.
### Hero com jogos simultâneos (2026-06-15)
- **A home agora exibe 2+ partidas quando caem no mesmo kickoff** (última rodada de grupos: ids 4972, 12 pares — mesmo grupo, estádios diferentes; confirmado na `matches.json`: 12 slots, **sempre exatamente 2**). Antes o hero só mostrava 1 jogo (`findFeaturedMatch`).
- **`findFeaturedMatch``findFeaturedMatches(now)`** (`app.js`): pega o jogo não-`over` de kickoff mais cedo e retorna **todos** que compartilham aquele kickoff exato (`getTime()`). Genérico (1/2/N), mas o dado real é sempre 2. `heroSignature(featured, now)` agora cobre o **conjunto** (`id:estado` join por `|`) → o `heroTick` de 1s já existente detecta entrada/saída/troca de estado e re-renderiza. **Mesmo timer/countdown**, como pedido.
- **`renderHero` ramifica:** 1 jogo = **DOM idêntico ao de antes** (sem wrapper, meta completa `hora · estádio, cidade`, regressão zero — verificado); 2+ = 1 rótulo plural compartilhado (`hero.nextMatches`, EN "Next matches"/PT "Próximas partidas") + 1 fase (todos do mesmo grupo) + **1 linha de hora compartilhada** (`.hero-time`) + N confrontos empilhados (`.hero-matchups` `.hero-match`, separados por `.hero-divider`), **cada um com seu próprio estádio** e seu próprio placar/`vs` + **1 countdown compartilhado**. Badge "Bola rolando!" e supressão do countdown são por-slot (todos compartilham kickoff+janela → estado de relógio sincronizado).
- **`heroMatchupHTML(match, now, multi)`** extraído (uma linha de confronto + meta; `multi` tira a hora da meta, deixa só o estádio).
- **Gotchas conhecidos:** (a) a hora compartilhada usa o estádio de `featured[0]` no modo "hora do estádio" — os pares reais são do mesmo fuso, então fica correto; (b) se um dos dois for marcado `finished` no JSON **antes** da janela do slot fechar, ele vira `over` e **sai** do conjunto (o hero mostraria só o outro até a janela acabar) — não ocorre na prática porque o update diário é feito depois que ambos já encerraram pelo relógio.
- **Verificado (preview, relógio fakeado):** real-now → 1 jogo idêntico (ESP-CPV, "Bola rolando!"); 24/06 18:30Z → 2 confrontos (SUI×CAN BC Place / BIH×QAT Lumen Field), "Próximas partidas Grupo B", hora compartilhada, 1 divisória, 1 countdown, 4 bandeiras; 19:30Z → ambos "Bola rolando!"/vs sem countdown; restauro do relógio volta ao hero de 1 jogo sem resíduo; EN "Next matches Group B"; mobile 375px empilha certo; console limpo. Screenshots desktop+mobile conferidos.
### How to update real-world data (scores, schedule)
Follow `how-refresh-data.md` (project root). In short:
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).

View file

@ -332,6 +332,32 @@ button {
font-size: 0.95rem;
}
/* two simultaneous matches (group-stage final round) stacked under one label */
.hero-matchups {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3.5vw, 1.75rem);
}
.hero-match {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.hero-divider {
width: min(420px, 80%);
height: 1px;
margin: 0 auto;
background: var(--glass-border);
}
.hero-time {
margin-top: 0.25rem;
color: var(--text-primary);
font-weight: 600;
}
/* --------------------------------------------------------- countdown */
.countdown {

View file

@ -128,19 +128,28 @@ export function matchState(match, result, now) {
return 'upcoming';
}
// Featured match = the earliest match that isn't over yet (in progress or
// upcoming); ties broken by id, matching schedule.js ordering.
function findFeaturedMatch(now) {
// Featured = the earliest matches that aren't over yet, INCLUDING every match
// sharing that exact kickoff. At the end of the group stage a group's last two
// games kick off simultaneously, so the hero must show both (they share kickoff
// + phase → same window → synced clock state). Returns [] when nothing is left.
function findFeaturedMatches(now) {
const { matches, resultByMatchId } = data;
return matches
const upNext = matches
.filter((m) => matchState(m, resultByMatchId.get(m.id), now) !== 'over')
.sort((a, b) => matchDateUTC(a) - matchDateUTC(b) || a.id - b.id)[0] ?? null;
.sort((a, b) => matchDateUTC(a) - matchDateUTC(b) || a.id - b.id);
if (!upNext.length) return [];
const kickoff = matchDateUTC(upNext[0]).getTime();
return upNext.filter((m) => matchDateUTC(m).getTime() === kickoff);
}
// Compact signature of "what the hero should show now"; a change drives a rebuild.
function heroSignature(match, now) {
if (!match) return '∅';
return `${match.id}:${matchState(match, data.resultByMatchId.get(match.id), now)}`;
// Covers the whole featured set so adding/removing a simultaneous match (or any
// of them flipping state) re-renders.
function heroSignature(featured, now) {
if (!featured.length) return '∅';
return featured
.map((m) => `${m.id}:${matchState(m, data.resultByMatchId.get(m.id), now)}`)
.join('|');
}
function heroTeamHTML(teamId) {
@ -158,19 +167,10 @@ let heroSig = null;
let countdownTarget = null;
let countdownEls = null;
function renderHero() {
const root = document.getElementById('hero-content');
const now = Date.now();
const match = findFeaturedMatch(now);
heroSig = heroSignature(match, now);
countdownTarget = null;
countdownEls = null;
if (!match) {
root.innerHTML = '';
startHeroClock();
return;
}
// One matchup row (teams + center) plus its meta line. `multi` drops the time
// from the meta (shown once, shared) and keeps only the stadium; a single match
// keeps the original "time · stadium, city" so the lone-match hero is unchanged.
function heroMatchupHTML(match, now, multi) {
const result = data.resultByMatchId.get(match.id);
const stadium = data.stadiumByName.get(match.stadium);
const live = matchState(match, result, now) === 'live';
@ -181,21 +181,60 @@ function renderHero() {
const center = live && hasScore
? `<div class="hero-score">${result.homeScore}<span class="hero-score-sep"></span>${result.awayScore}</div>`
: `<div class="hero-vs">${t('hero.vs')}</div>`;
const meta = multi
? `${match.stadium}, ${match.city}`
: `${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}`;
root.innerHTML = `
<p class="hero-label">
${live ? `<span class="live-badge pulse">● ${t('hero.inProgress')}</span>` : t('hero.nextMatch')}
<span class="hero-phase">${translatePhase(match.phase)}</span>
</p>
return `
<div class="hero-matchup">
${heroTeamHTML(match.homeTeam)}
${center}
${heroTeamHTML(match.awayTeam)}
</div>
<p class="hero-meta">${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}</p>
<p class="hero-meta">${meta}</p>`;
}
function renderHero() {
const root = document.getElementById('hero-content');
const now = Date.now();
const featured = findFeaturedMatches(now);
heroSig = heroSignature(featured, now);
countdownTarget = null;
countdownEls = null;
if (!featured.length) {
root.innerHTML = '';
startHeroClock();
return;
}
// Simultaneous matches share kickoff + phase, so one label, one shared time
// and one countdown cover the whole set; each matchup keeps its own score.
const multi = featured.length > 1;
const live = featured.some((m) => matchState(m, data.resultByMatchId.get(m.id), now) === 'live');
const phase = translatePhase(featured[0].phase);
const rows = featured
.map((m) => (multi ? `<div class="hero-match">${heroMatchupHTML(m, now, true)}</div>` : heroMatchupHTML(m, now, false)))
.join(multi ? '<div class="hero-divider" aria-hidden="true"></div>' : '');
const body = multi ? `<div class="hero-matchups">${rows}</div>` : rows;
// shared kickoff time, shown once. Real simultaneous pairs are same-timezone,
// so the first match's stadium gives the right time even in stadium-time mode.
const sharedTime = multi
? `<p class="hero-meta hero-time">${formatMatchTime(featured[0], data.stadiumByName.get(featured[0].stadium))}</p>`
: '';
root.innerHTML = `
<p class="hero-label">
${live ? `<span class="live-badge pulse">● ${t('hero.inProgress')}</span>` : t(multi ? 'hero.nextMatches' : 'hero.nextMatch')}
<span class="hero-phase">${phase}</span>
</p>
${sharedTime}
${body}
${live ? '' : `<div class="countdown" id="countdown" role="timer" aria-label="${t('hero.countdownLabel')}"></div>`}
`;
if (!live) setupCountdown(matchDateUTC(match).getTime());
if (!live) setupCountdown(matchDateUTC(featured[0]).getTime());
startHeroClock();
}
@ -233,7 +272,7 @@ function startHeroClock() {
function heroTick() {
const now = Date.now();
const sig = heroSignature(findFeaturedMatch(now), now);
const sig = heroSignature(findFeaturedMatches(now), now);
if (sig !== heroSig) renderHero();
else updateCountdown();
}

View file

@ -18,6 +18,7 @@ const dicts = {
'nav.stats': 'Stats',
'hero.live': 'Live',
'hero.nextMatch': 'Next match',
'hero.nextMatches': 'Next matches',
'hero.inProgress': 'In progress',
'hero.countdownLabel': 'Time until kickoff',
'hero.vs': 'vs',
@ -157,6 +158,7 @@ const dicts = {
'nav.stats': 'Estatísticas',
'hero.live': 'Ao vivo',
'hero.nextMatch': 'Próxima partida',
'hero.nextMatches': 'Próximas partidas',
'hero.inProgress': 'Bola rolando!',
'hero.countdownLabel': 'Tempo até o início da partida',
'hero.vs': 'vs',