mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(schedule): add occurrence filter with pending result status
This commit is contained in:
parent
5cbd3e6f3d
commit
99ea02a604
6 changed files with 122 additions and 10 deletions
|
|
@ -39,7 +39,9 @@ worldcup2026/
|
||||||
│ │ │ tab routing + lastTab, formatMatchTime(), dashboard,
|
│ │ │ tab routing + lastTab, formatMatchTime(), dashboard,
|
||||||
│ │ │ clock-driven hero (matchState/findFeaturedMatch +
|
│ │ │ clock-driven hero (matchState/findFeaturedMatch +
|
||||||
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h match window)
|
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h match window)
|
||||||
│ │ ├── schedule.js Match list, filters, search, sort, "My Matches"
|
│ │ ├── schedule.js Match list, filters (incl. occurrence toggle
|
||||||
|
│ │ │ Played/Upcoming via hybrid matchState), search,
|
||||||
|
│ │ │ sort, "My Matches"; 60s clock-tick re-render
|
||||||
│ │ ├── groups.js Standings computation (3/1/0, GD, GF) + group tables
|
│ │ ├── groups.js Standings computation (3/1/0, GD, GF) + group tables
|
||||||
│ │ ├── stadiums.js Stadium cards + "view matches" cross-link
|
│ │ ├── stadiums.js Stadium cards + "view matches" cross-link
|
||||||
│ │ ├── bracket.js ★ Bracket tree resolution, resolveBracketTeams(),
|
│ │ ├── bracket.js ★ Bracket tree resolution, resolveBracketTeams(),
|
||||||
|
|
@ -154,6 +156,7 @@ matches.json time (UTC) ── formatMatchTime(match, stadium, mode)
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `loadData` | `assets/js/app.js` | `()` | `Promise<AppData>` | Fetches all `data/*.json` in parallel, caches in memory |
|
| `loadData` | `assets/js/app.js` | `()` | `Promise<AppData>` | Fetches all `data/*.json` in parallel, caches in memory |
|
||||||
| `formatMatchTime` | `assets/js/app.js` | `(match, stadium, mode)` | `string` | UTC → display time; `mode` is `"local"` or `"stadium"` |
|
| `formatMatchTime` | `assets/js/app.js` | `(match, stadium, mode)` | `string` | UTC → display time; `mode` is `"local"` or `"stadium"` |
|
||||||
|
| `matchState` | `assets/js/app.js` | `(match, result, now)` | `'over' \| 'live' \| 'upcoming'` | Hybrid JSON+clock state (finished/live win; else clock advances at kickoff/kickoff+window). Used by the hero **and** the schedule occurrence filter / "Awaiting result" chip |
|
||||||
| `get` / `set` | `assets/js/storage.js` | `(key, fallback)` / `(key, value)` | `any` / `void` | localStorage wrapper, auto JSON parse/stringify |
|
| `get` / `set` | `assets/js/storage.js` | `(key, fallback)` / `(key, value)` | `any` / `void` | localStorage wrapper, auto JSON parse/stringify |
|
||||||
| `t` | `assets/js/i18n.js` | `(key)` | `string` | Translated UI string for current lang |
|
| `t` | `assets/js/i18n.js` | `(key)` | `string` | Translated UI string for current lang |
|
||||||
| `resolveBracketTeams` | `assets/js/bracket.js` | `(matchOrRef)` | `{ home, away }` of `{ team, label }` | Display slots for any match (group or knockout); reused by schedule/modal/filters |
|
| `resolveBracketTeams` | `assets/js/bracket.js` | `(matchOrRef)` | `{ home, away }` of `{ team, label }` | Display slots for any match (group or knockout); reused by schedule/modal/filters |
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,14 @@ Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) —
|
||||||
- **Fim do torneio:** quando a Final vira `over`, `findFeaturedMatch` retorna `null` → hero vazio (comportamento mantido). TODO registrado: estado pós-Copa da home (campeão/epílogo) quando a Final encerrar.
|
- **Fim do torneio:** quando a Final vira `over`, `findFeaturedMatch` retorna `null` → hero vazio (comportamento mantido). TODO registrado: estado pós-Copa da home (campeão/epílogo) quando a Final encerrar.
|
||||||
- **Verificado (preview, relógio fakeado via `Date.now` override + dispatch `langchange`):** 01:00Z→m12 upcoming (countdown 01:00:00); 02:30Z→m12 "Bola rolando!"+vs sem placar/sem cronômetro; 04:30Z→avança pro m13 (m12 ainda `scheduled` no JSON!); 18:30Z→avança pro m14; EN mostra "In progress"; console limpo. Screenshots de upcoming e in-progress conferidos.
|
- **Verificado (preview, relógio fakeado via `Date.now` override + dispatch `langchange`):** 01:00Z→m12 upcoming (countdown 01:00:00); 02:30Z→m12 "Bola rolando!"+vs sem placar/sem cronômetro; 04:30Z→avança pro m13 (m12 ainda `scheduled` no JSON!); 18:30Z→avança pro m14; EN mostra "In progress"; console limpo. Screenshots de upcoming e in-progress conferidos.
|
||||||
|
|
||||||
|
### Filtro de ocorrência na aba Matches (2026-06-15)
|
||||||
|
- **Novo botão de 3 estados** na `filter-row` do schedule que cicla `Todos → Já ocorreram → A ocorrer` (`state.occurred` = `''`/`'occurred'`/`'upcoming'`, via `OCC_CYCLE`). Em memória, **não persistido** — segue a convenção dos outros filtros. `.active` (dourado) quando ≠ 'Todos', igual ao `★ Minhas partidas`. Resetado por **Clear** e `setStadiumFilter`.
|
||||||
|
- **Reusa a regra híbrida do hero:** `matchState(match, result, now)` foi **exportada de `app.js`** (antes privada) e importada por `schedule.js` — fonte única, sem duplicar a lógica. "Já ocorreram" = estado `over` (status `finished` **OU** relógio passou `kickoff + janela`, 2h grupo / 3h mata-mata); "A ocorrer" = `live` + `upcoming` (jogo ao vivo ainda não "ocorreu").
|
||||||
|
- **Inconsistência relógio×JSON resolvida no card:** quando `status==='scheduled'` mas `matchState==='over'`, o card mostra o chip **"Pendente de resultado"** (`status.pending`; EN "Awaiting result"; cor `--accent-blue`, `.match-status.pending`) em vez do "vs" mudo. Os badges live de Matches/Modal/Bracket continuam 100% guiados pelo `status` do JSON (escopo do hero inteligente mantido — só o caso `over` ganhou tratamento aqui).
|
||||||
|
- **Lista fica fresca via timer leve de 60s** (`startOccurrenceClock`/`countOverMatches`, `OCC_TICK_MS`): assinatura = **nº de jogos `over` agora** (monotônico, pois `over` nunca volta). Só `renderList()` quando a contagem muda → nada de repaint dos 104 cards por segundo. `renderList` sincroniza a baseline `overSignature` ao final (qualquer re-render por evento mantém o timer em dia).
|
||||||
|
- **`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.
|
||||||
|
|
||||||
### How to update real-world data (scores, schedule)
|
### How to update real-world data (scores, schedule)
|
||||||
Follow `how-refresh-data.md` (project root). In short:
|
Follow `how-refresh-data.md` (project root). In short:
|
||||||
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,11 @@ button {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.match-status.pending {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.match-teams {
|
.match-teams {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
|
@ -977,6 +982,23 @@ dialog.match-modal::backdrop {
|
||||||
color: var(--accent-gold);
|
color: var(--accent-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.occ-filter {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.occ-filter:hover {
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.occ-filter.active {
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
background: rgba(212, 175, 55, 0.16);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,8 @@ function matchWindowMs(match) {
|
||||||
// Hybrid state of a match at instant `now`: the JSON wins when it says finished
|
// Hybrid state of a match at instant `now`: the JSON wins when it says finished
|
||||||
// or live; otherwise the clock advances the state so the hero flips at kickoff
|
// or live; otherwise the clock advances the state so the hero flips at kickoff
|
||||||
// and again at kickoff+window with no JSON edit. Pure function, easy to reason about.
|
// and again at kickoff+window with no JSON edit. Pure function, easy to reason about.
|
||||||
function matchState(match, result, now) {
|
// Exported so schedule.js shares the exact same hybrid rule (occurrence filter + chip).
|
||||||
|
export function matchState(match, result, now) {
|
||||||
const status = result?.status ?? 'scheduled';
|
const status = result?.status ?? 'scheduled';
|
||||||
const kickoff = matchDateUTC(match).getTime();
|
const kickoff = matchDateUTC(match).getTime();
|
||||||
if (status === 'finished' || now >= kickoff + matchWindowMs(match)) return 'over';
|
if (status === 'finished' || now >= kickoff + matchWindowMs(match)) return 'over';
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const dicts = {
|
||||||
'stadiums.capacity': 'Capacity',
|
'stadiums.capacity': 'Capacity',
|
||||||
'stadiums.viewMatches': 'View matches',
|
'stadiums.viewMatches': 'View matches',
|
||||||
'status.scheduled': 'Scheduled',
|
'status.scheduled': 'Scheduled',
|
||||||
|
'status.pending': 'Awaiting result',
|
||||||
'modal.close': 'Close',
|
'modal.close': 'Close',
|
||||||
'modal.date': 'Date & time',
|
'modal.date': 'Date & time',
|
||||||
'modal.stadium': 'Stadium',
|
'modal.stadium': 'Stadium',
|
||||||
|
|
@ -100,6 +101,10 @@ const dicts = {
|
||||||
'time.stadium': 'Stadium time',
|
'time.stadium': 'Stadium time',
|
||||||
'time.toggleAria': 'Toggle between local and stadium time',
|
'time.toggleAria': 'Toggle between local and stadium time',
|
||||||
'schedule.myMatches': 'My matches',
|
'schedule.myMatches': 'My matches',
|
||||||
|
'schedule.occAria': 'Occurrence filter',
|
||||||
|
'schedule.occAll': 'All matches',
|
||||||
|
'schedule.occPlayed': 'Played',
|
||||||
|
'schedule.occUpcoming': 'Upcoming',
|
||||||
'fav.toggle': 'Favorite',
|
'fav.toggle': 'Favorite',
|
||||||
'challenge.title': 'Bracket challenge',
|
'challenge.title': 'Bracket challenge',
|
||||||
'challenge.correct': '{x} of {y} picks correct',
|
'challenge.correct': '{x} of {y} picks correct',
|
||||||
|
|
@ -206,6 +211,7 @@ const dicts = {
|
||||||
'stadiums.capacity': 'Capacidade',
|
'stadiums.capacity': 'Capacidade',
|
||||||
'stadiums.viewMatches': 'Ver partidas',
|
'stadiums.viewMatches': 'Ver partidas',
|
||||||
'status.scheduled': 'Agendada',
|
'status.scheduled': 'Agendada',
|
||||||
|
'status.pending': 'Pendente de resultado',
|
||||||
'modal.close': 'Fechar',
|
'modal.close': 'Fechar',
|
||||||
'modal.date': 'Data e hora',
|
'modal.date': 'Data e hora',
|
||||||
'modal.stadium': 'Estádio',
|
'modal.stadium': 'Estádio',
|
||||||
|
|
@ -234,6 +240,10 @@ const dicts = {
|
||||||
'time.stadium': 'Hora do estádio',
|
'time.stadium': 'Hora do estádio',
|
||||||
'time.toggleAria': 'Alternar entre hora local e do estádio',
|
'time.toggleAria': 'Alternar entre hora local e do estádio',
|
||||||
'schedule.myMatches': 'Minhas partidas',
|
'schedule.myMatches': 'Minhas partidas',
|
||||||
|
'schedule.occAria': 'Filtro de ocorrência',
|
||||||
|
'schedule.occAll': 'Todos os jogos',
|
||||||
|
'schedule.occPlayed': 'Já ocorreram',
|
||||||
|
'schedule.occUpcoming': 'A ocorrer',
|
||||||
'fav.toggle': 'Favoritar',
|
'fav.toggle': 'Favoritar',
|
||||||
'challenge.title': 'Bolão do mata-mata',
|
'challenge.title': 'Bolão do mata-mata',
|
||||||
'challenge.correct': '{x} de {y} palpites certos',
|
'challenge.correct': '{x} de {y} palpites certos',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// team, stadium), diacritic-insensitive search, date sort.
|
// team, stadium), diacritic-insensitive search, date sort.
|
||||||
// Knockout team names show as TBD until resolveBracketTeams() lands (step 7).
|
// Knockout team names show as TBD until resolveBracketTeams() lands (step 7).
|
||||||
|
|
||||||
import { getData, formatMatchTime, matchDateUTC, flagSrc } from './app.js';
|
import { getData, formatMatchTime, matchDateUTC, flagSrc, matchState } from './app.js';
|
||||||
import { t, translatePhase } from './i18n.js';
|
import { t, translatePhase } from './i18n.js';
|
||||||
import { openMatchModal } from './modal.js';
|
import { openMatchModal } from './modal.js';
|
||||||
import { resolveBracketTeams, getFavoriteMatches } from './bracket.js';
|
import { resolveBracketTeams, getFavoriteMatches } from './bracket.js';
|
||||||
|
|
@ -10,11 +10,22 @@ import { getFavorites } from './storage.js';
|
||||||
|
|
||||||
const KNOCKOUT_PHASES = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final'];
|
const KNOCKOUT_PHASES = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final'];
|
||||||
|
|
||||||
const state = { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false };
|
// `occurred`: '' (all) | 'occurred' (over by clock/JSON) | 'upcoming' (live + not started)
|
||||||
|
const state = { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false, occurred: '' };
|
||||||
|
|
||||||
|
// Re-render driver for the clock crossing a match window while the list is open.
|
||||||
|
// Signature = how many matches are "over" right now (monotonic), so a coarse 60s
|
||||||
|
// tick re-renders only when something actually flipped — no per-second card repaint.
|
||||||
|
const OCC_TICK_MS = 60 * 1000;
|
||||||
|
let occTimer = null;
|
||||||
|
let overSignature = -1;
|
||||||
|
|
||||||
|
const OCC_CYCLE = ['', 'occurred', 'upcoming'];
|
||||||
|
|
||||||
export function initSchedule() {
|
export function initSchedule() {
|
||||||
renderToolbar();
|
renderToolbar();
|
||||||
renderList();
|
renderList();
|
||||||
|
startOccurrenceClock();
|
||||||
document.addEventListener('langchange', () => {
|
document.addEventListener('langchange', () => {
|
||||||
renderToolbar();
|
renderToolbar();
|
||||||
renderList();
|
renderList();
|
||||||
|
|
@ -79,6 +90,8 @@ function renderToolbar() {
|
||||||
<option value="">${t('schedule.allStadiums')}</option>${stadiumOptions}
|
<option value="">${t('schedule.allStadiums')}</option>${stadiumOptions}
|
||||||
</select>
|
</select>
|
||||||
<button id="sched-sort" class="filter-control sort-btn"></button>
|
<button id="sched-sort" class="filter-control sort-btn"></button>
|
||||||
|
<button id="sched-occ" class="filter-control occ-filter ${state.occurred ? 'active' : ''}"
|
||||||
|
aria-label="${occAriaLabel()}">${occButtonLabel()}</button>
|
||||||
<button id="sched-fav" class="filter-control fav-filter ${state.favOnly ? 'active' : ''}"
|
<button id="sched-fav" class="filter-control fav-filter ${state.favOnly ? 'active' : ''}"
|
||||||
aria-pressed="${state.favOnly}">★ ${t('schedule.myMatches')}</button>
|
aria-pressed="${state.favOnly}">★ ${t('schedule.myMatches')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,6 +122,14 @@ function renderToolbar() {
|
||||||
syncSortLabel();
|
syncSortLabel();
|
||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
|
byId('sched-occ').addEventListener('click', () => {
|
||||||
|
state.occurred = OCC_CYCLE[(OCC_CYCLE.indexOf(state.occurred) + 1) % OCC_CYCLE.length];
|
||||||
|
const btn = byId('sched-occ');
|
||||||
|
btn.classList.toggle('active', Boolean(state.occurred));
|
||||||
|
btn.textContent = occButtonLabel();
|
||||||
|
btn.setAttribute('aria-label', occAriaLabel());
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
byId('sched-fav').addEventListener('click', () => {
|
byId('sched-fav').addEventListener('click', () => {
|
||||||
state.favOnly = !state.favOnly;
|
state.favOnly = !state.favOnly;
|
||||||
const btn = byId('sched-fav');
|
const btn = byId('sched-fav');
|
||||||
|
|
@ -117,7 +138,7 @@ function renderToolbar() {
|
||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
byId('sched-clear').addEventListener('click', () => {
|
byId('sched-clear').addEventListener('click', () => {
|
||||||
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false });
|
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false, occurred: '' });
|
||||||
renderToolbar();
|
renderToolbar();
|
||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
|
|
@ -129,7 +150,7 @@ function byId(id) {
|
||||||
|
|
||||||
// external entry point (stadiums page) — show only matches at one stadium
|
// external entry point (stadiums page) — show only matches at one stadium
|
||||||
export function setStadiumFilter(stadiumName) {
|
export function setStadiumFilter(stadiumName) {
|
||||||
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: stadiumName, favOnly: false });
|
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: stadiumName, favOnly: false, occurred: '' });
|
||||||
renderToolbar();
|
renderToolbar();
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
@ -143,13 +164,34 @@ function syncSortLabel() {
|
||||||
byId('sched-sort').textContent = t(state.sort === 'asc' ? 'schedule.sortAsc' : 'schedule.sortDesc');
|
byId('sched-sort').textContent = t(state.sort === 'asc' ? 'schedule.sortAsc' : 'schedule.sortDesc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function occStateLabel() {
|
||||||
|
if (state.occurred === 'occurred') return t('schedule.occPlayed');
|
||||||
|
if (state.occurred === 'upcoming') return t('schedule.occUpcoming');
|
||||||
|
return t('schedule.occAll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function occButtonLabel() {
|
||||||
|
return `◷ ${occStateLabel()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function occAriaLabel() {
|
||||||
|
return `${t('schedule.occAria')}: ${occStateLabel()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ filtering
|
// ------------------------------------------------------------ filtering
|
||||||
|
|
||||||
function normalize(text) {
|
function normalize(text) {
|
||||||
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilters(match) {
|
function matchesFilters(match, now) {
|
||||||
|
if (state.occurred) {
|
||||||
|
// hybrid rule (same as the home hero): "over" = finished in JSON OR clock past
|
||||||
|
// kickoff + window. "upcoming" bucket keeps live + not-yet-started together.
|
||||||
|
const over = matchState(match, getData().resultByMatchId.get(match.id), now) === 'over';
|
||||||
|
if (state.occurred === 'occurred' && !over) return false;
|
||||||
|
if (state.occurred === 'upcoming' && over) return false;
|
||||||
|
}
|
||||||
if (state.date && match.date !== state.date) return false;
|
if (state.date && match.date !== state.date) return false;
|
||||||
if (state.group && match.phase !== `Group ${state.group}`) return false;
|
if (state.group && match.phase !== `Group ${state.group}`) return false;
|
||||||
if (state.phase === 'groups' && !match.phase.startsWith('Group')) return false;
|
if (state.phase === 'groups' && !match.phase.startsWith('Group')) return false;
|
||||||
|
|
@ -171,19 +213,41 @@ function matchesFilters(match) {
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const { matches } = getData();
|
const { matches } = getData();
|
||||||
|
const now = Date.now();
|
||||||
const direction = state.sort === 'asc' ? 1 : -1;
|
const direction = state.sort === 'asc' ? 1 : -1;
|
||||||
const favIds = state.favOnly
|
const favIds = state.favOnly
|
||||||
? new Set(getFavoriteMatches(matches, getFavorites()).map((m) => m.id))
|
? new Set(getFavoriteMatches(matches, getFavorites()).map((m) => m.id))
|
||||||
: null;
|
: null;
|
||||||
const filtered = matches
|
const filtered = matches
|
||||||
.filter((m) => (!favIds || favIds.has(m.id)) && matchesFilters(m))
|
.filter((m) => (!favIds || favIds.has(m.id)) && matchesFilters(m, now))
|
||||||
.sort((a, b) => direction * (matchDateUTC(a) - matchDateUTC(b) || a.id - b.id));
|
.sort((a, b) => direction * (matchDateUTC(a) - matchDateUTC(b) || a.id - b.id));
|
||||||
|
|
||||||
byId('sched-count').textContent =
|
byId('sched-count').textContent =
|
||||||
`${filtered.length} ${filtered.length === 1 ? t('schedule.match') : t('schedule.matches')}`;
|
`${filtered.length} ${filtered.length === 1 ? t('schedule.match') : t('schedule.matches')}`;
|
||||||
byId('sched-list').innerHTML = filtered.length
|
byId('sched-list').innerHTML = filtered.length
|
||||||
? filtered.map(matchCardHTML).join('')
|
? filtered.map((m) => matchCardHTML(m, now)).join('')
|
||||||
: `<p class="placeholder glass">${t('schedule.noResults')}</p>`;
|
: `<p class="placeholder glass">${t('schedule.noResults')}</p>`;
|
||||||
|
|
||||||
|
// keep the clock-tick baseline in sync with whatever we just rendered
|
||||||
|
overSignature = countOverMatches(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// number of matches "over" at `now` — monotonic, so the 60s tick re-renders
|
||||||
|
// only when the clock pushes another match past the end of its window.
|
||||||
|
function countOverMatches(now) {
|
||||||
|
const { matches, resultByMatchId } = getData();
|
||||||
|
let n = 0;
|
||||||
|
for (const m of matches) {
|
||||||
|
if (matchState(m, resultByMatchId.get(m.id), now) === 'over') n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOccurrenceClock() {
|
||||||
|
if (occTimer) return;
|
||||||
|
occTimer = setInterval(() => {
|
||||||
|
if (countOverMatches(Date.now()) !== overSignature) renderList();
|
||||||
|
}, OCC_TICK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function teamColumnHTML(slot) {
|
function teamColumnHTML(slot) {
|
||||||
|
|
@ -199,15 +263,19 @@ function teamColumnHTML(slot) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchCardHTML(match) {
|
function matchCardHTML(match, now = Date.now()) {
|
||||||
const { resultByMatchId, stadiumByName } = getData();
|
const { resultByMatchId, stadiumByName } = getData();
|
||||||
const result = resultByMatchId.get(match.id);
|
const result = resultByMatchId.get(match.id);
|
||||||
const status = result?.status ?? 'scheduled';
|
const status = result?.status ?? 'scheduled';
|
||||||
const stadium = stadiumByName.get(match.stadium);
|
const stadium = stadiumByName.get(match.stadium);
|
||||||
|
// scheduled in JSON but the clock says its window already closed → the data
|
||||||
|
// just hasn't caught up. Flag it instead of silently showing a plain "vs".
|
||||||
|
const pending = status === 'scheduled' && matchState(match, result, now) === 'over';
|
||||||
|
|
||||||
let statusChip = '';
|
let statusChip = '';
|
||||||
if (status === 'live') statusChip = `<span class="match-status live pulse">● ${t('hero.live')}</span>`;
|
if (status === 'live') statusChip = `<span class="match-status live pulse">● ${t('hero.live')}</span>`;
|
||||||
else if (status === 'finished') statusChip = `<span class="match-status finished">${t('status.finished')}</span>`;
|
else if (status === 'finished') statusChip = `<span class="match-status finished">${t('status.finished')}</span>`;
|
||||||
|
else if (pending) statusChip = `<span class="match-status pending">${t('status.pending')}</span>`;
|
||||||
|
|
||||||
let center;
|
let center;
|
||||||
if (status === 'finished' || status === 'live') {
|
if (status === 'finished' || status === 'live') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue