mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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:
parent
99ea02a604
commit
6e33142c96
5 changed files with 107 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 49–72, 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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue