From 99ea02a60492ac226189ed73fd6eba583fb48faf Mon Sep 17 00:00:00 2001 From: Lucas Kalil Date: Mon, 15 Jun 2026 14:33:33 -0300 Subject: [PATCH] feat(schedule): add occurrence filter with pending result status --- .agents/project-map.md | 5 ++- .agents/project-memory.md | 8 ++++ assets/css/style.css | 22 ++++++++++ assets/js/app.js | 3 +- assets/js/i18n.js | 10 +++++ assets/js/schedule.js | 84 +++++++++++++++++++++++++++++++++++---- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.agents/project-map.md b/.agents/project-map.md index 74196ed..787a3d8 100644 --- a/.agents/project-map.md +++ b/.agents/project-map.md @@ -39,7 +39,9 @@ worldcup2026/ │ │ │ tab routing + lastTab, formatMatchTime(), dashboard, │ │ │ clock-driven hero (matchState/findFeaturedMatch + │ │ │ 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 │ │ ├── stadiums.js Stadium cards + "view matches" cross-link │ │ ├── bracket.js ★ Bracket tree resolution, resolveBracketTeams(), @@ -154,6 +156,7 @@ matches.json time (UTC) ── formatMatchTime(match, stadium, mode) |---|---|---|---|---| | `loadData` | `assets/js/app.js` | `()` | `Promise` | 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"` | +| `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 | | `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 | diff --git a/.agents/project-memory.md b/.agents/project-memory.md index d9204e9..701d1bb 100644 --- a/.agents/project-memory.md +++ b/.agents/project-memory.md @@ -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. - **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) Follow `how-refresh-data.md` (project root). In short: 1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare). diff --git a/assets/css/style.css b/assets/css/style.css index aba96a0..b004e0b 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -544,6 +544,11 @@ button { color: var(--text-secondary); } +.match-status.pending { + color: var(--accent-blue); + font-weight: 600; +} + .match-teams { display: grid; grid-template-columns: 1fr auto 1fr; @@ -977,6 +982,23 @@ dialog.match-modal::backdrop { 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 { display: flex; justify-content: flex-end; diff --git a/assets/js/app.js b/assets/js/app.js index bbf139a..f322173 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -119,7 +119,8 @@ function matchWindowMs(match) { // 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 // 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 kickoff = matchDateUTC(match).getTime(); if (status === 'finished' || now >= kickoff + matchWindowMs(match)) return 'over'; diff --git a/assets/js/i18n.js b/assets/js/i18n.js index dfc5beb..5c715c4 100644 --- a/assets/js/i18n.js +++ b/assets/js/i18n.js @@ -72,6 +72,7 @@ const dicts = { 'stadiums.capacity': 'Capacity', 'stadiums.viewMatches': 'View matches', 'status.scheduled': 'Scheduled', + 'status.pending': 'Awaiting result', 'modal.close': 'Close', 'modal.date': 'Date & time', 'modal.stadium': 'Stadium', @@ -100,6 +101,10 @@ const dicts = { 'time.stadium': 'Stadium time', 'time.toggleAria': 'Toggle between local and stadium time', 'schedule.myMatches': 'My matches', + 'schedule.occAria': 'Occurrence filter', + 'schedule.occAll': 'All matches', + 'schedule.occPlayed': 'Played', + 'schedule.occUpcoming': 'Upcoming', 'fav.toggle': 'Favorite', 'challenge.title': 'Bracket challenge', 'challenge.correct': '{x} of {y} picks correct', @@ -206,6 +211,7 @@ const dicts = { 'stadiums.capacity': 'Capacidade', 'stadiums.viewMatches': 'Ver partidas', 'status.scheduled': 'Agendada', + 'status.pending': 'Pendente de resultado', 'modal.close': 'Fechar', 'modal.date': 'Data e hora', 'modal.stadium': 'Estádio', @@ -234,6 +240,10 @@ const dicts = { 'time.stadium': 'Hora do estádio', 'time.toggleAria': 'Alternar entre hora local e do estádio', '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', 'challenge.title': 'Bolão do mata-mata', 'challenge.correct': '{x} de {y} palpites certos', diff --git a/assets/js/schedule.js b/assets/js/schedule.js index 0819267..6c31808 100644 --- a/assets/js/schedule.js +++ b/assets/js/schedule.js @@ -2,7 +2,7 @@ // team, stadium), diacritic-insensitive search, date sort. // 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 { openMatchModal } from './modal.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 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() { renderToolbar(); renderList(); + startOccurrenceClock(); document.addEventListener('langchange', () => { renderToolbar(); renderList(); @@ -79,6 +90,8 @@ function renderToolbar() { ${stadiumOptions} + @@ -109,6 +122,14 @@ function renderToolbar() { syncSortLabel(); 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', () => { state.favOnly = !state.favOnly; const btn = byId('sched-fav'); @@ -117,7 +138,7 @@ function renderToolbar() { renderList(); }); 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(); renderList(); }); @@ -129,7 +150,7 @@ function byId(id) { // external entry point (stadiums page) — show only matches at one stadium 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(); renderList(); } @@ -143,13 +164,34 @@ function syncSortLabel() { 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 function normalize(text) { 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.group && match.phase !== `Group ${state.group}`) return false; if (state.phase === 'groups' && !match.phase.startsWith('Group')) return false; @@ -171,19 +213,41 @@ function matchesFilters(match) { function renderList() { const { matches } = getData(); + const now = Date.now(); const direction = state.sort === 'asc' ? 1 : -1; const favIds = state.favOnly ? new Set(getFavoriteMatches(matches, getFavorites()).map((m) => m.id)) : null; 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)); byId('sched-count').textContent = `${filtered.length} ${filtered.length === 1 ? t('schedule.match') : t('schedule.matches')}`; byId('sched-list').innerHTML = filtered.length - ? filtered.map(matchCardHTML).join('') + ? filtered.map((m) => matchCardHTML(m, now)).join('') : `

${t('schedule.noResults')}

`; + + // 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) { @@ -199,15 +263,19 @@ function teamColumnHTML(slot) { `; } -function matchCardHTML(match) { +function matchCardHTML(match, now = Date.now()) { const { resultByMatchId, stadiumByName } = getData(); const result = resultByMatchId.get(match.id); const status = result?.status ?? 'scheduled'; 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 = ''; if (status === 'live') statusChip = `● ${t('hero.live')}`; else if (status === 'finished') statusChip = `${t('status.finished')}`; + else if (pending) statusChip = `${t('status.pending')}`; let center; if (status === 'finished' || status === 'live') {