diff --git a/Sports/FootballVMProphet/README.md b/Sports/FootballVMProphet/README.md new file mode 100644 index 0000000..7330c0f --- /dev/null +++ b/Sports/FootballVMProphet/README.md @@ -0,0 +1,123 @@ +# Football VM Prophet + +Free local web dashboard for FIFA World Cup 2026 men. + +VM Prophet is a local command center for fixtures, live-status health, upcoming/past match filtering, and manual odds tracking. It is built for **free data first**: no paid API key is required and stale/broken data is shown loudly. + +## What it does + +- Lists FIFA World Cup 2026 matches from a local fixture JSON file. +- Filters by today, live, upcoming, past, due soon, stage, team, venue, and data status. +- Shows detailed upcoming cards with countdown, venue, source freshness, and watchlist flags. +- Supports manual odds CSV import for 1X2 / over-under / BTTS tracking. +- Calculates implied probability and movement from imported odds snapshots. +- Shows data-health status: fixture file, live source, odds file, cache age, stale/broken warnings. +- Runs entirely locally with Node.js and browser HTML/JS. + +## What it does not do + +- It does not place bets. +- It does not guarantee predictions. +- It does not hide stale data behind a "live" label. +- It does not require paid APIs. +- It does not include official final World Cup 2026 fixture data before the draw/schedule is complete. The included fixture file is a starter/seed structure that you must update when official matches are available. + +## Run locally + +```powershell +cd Sports\FootballVMProphet +npm install +npm start +``` + +Open: + +```text +http://localhost:8787 +``` + +No dependencies are required beyond Node.js 18+. `npm install` is effectively a metadata check because the app uses only built-in Node modules. + +## Free data model + +VM Prophet uses three source layers: + +1. `data/fixtures.worldcup2026.json` - local source of truth for fixtures. +2. Optional free live endpoint - configured with environment variable `FREE_LIVE_ENDPOINT` if you later find a free source. +3. `data/odds.manual.csv` - manual odds snapshots that you control. + +If the live endpoint is not configured or fails, the dashboard stays usable and marks live data as disabled/broken/stale. + +## Manual odds CSV + +Copy the sample file: + +```powershell +Copy-Item .\data\odds.manual.example.csv .\data\odds.manual.csv +``` + +Format: + +```csv +matchId,snapshotTime,bookmaker,market,selection,odds +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Home,1.95 +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Draw,3.40 +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Away,4.20 +``` + +The app computes implied probability as `1 / decimal odds` and highlights movement between snapshots. + +## Optional environment variables + +```powershell +$env:VM_PROPHET_PORT = "8787" +$env:VM_PROPHET_TIMEZONE = "Europe/Oslo" +$env:FREE_LIVE_ENDPOINT = "https://example.invalid/free-live-football.json" +``` + +`FREE_LIVE_ENDPOINT` must return JSON shaped like: + +```json +{ + "matches": [ + { + "matchId": "wc2026-g01-m01", + "status": "LIVE", + "homeScore": 1, + "awayScore": 0, + "minute": 62, + "updatedAt": "2026-06-11T19:22:00Z" + } + ] +} +``` + +## File layout + +```text +Sports/FootballVMProphet/ +├── package.json +├── README.md +├── data/ +│ ├── fixtures.worldcup2026.json +│ ├── odds.manual.example.csv +│ └── watchlist.json +├── server/ +│ ├── server.js +│ ├── cache/ +│ └── sources/ +│ ├── free-live-source.js +│ ├── local-fixtures.js +│ └── manual-odds.js +├── web/ +│ ├── index.html +│ ├── app.js +│ └── styles.css +└── docs/ + ├── Data-Model.md + └── Free-Source-Notes.md +``` + +## Betting warning + +Use this as an evidence dashboard, not a prediction machine. Free sources may be delayed or wrong. The dashboard labels freshness so you can decide whether a signal is usable. diff --git a/Sports/FootballVMProphet/data/fixtures.worldcup2026.json b/Sports/FootballVMProphet/data/fixtures.worldcup2026.json new file mode 100644 index 0000000..ac0dc29 --- /dev/null +++ b/Sports/FootballVMProphet/data/fixtures.worldcup2026.json @@ -0,0 +1,75 @@ +{ + "dataQuality": "seed", + "tournament": { + "name": "FIFA World Cup 2026 Men", + "timezone": "Europe/Oslo", + "note": "Seed schedule structure only. Replace TBD fixtures after the official draw and match schedule are confirmed." + }, + "matches": [ + { + "matchId": "wc2026-g01-m01", + "stage": "Group Stage", + "group": "Group A", + "homeTeam": "TBD A1", + "awayTeam": "TBD A2", + "kickoffUtc": "2026-06-11T19:00:00Z", + "venue": "Estadio Azteca", + "city": "Mexico City", + "status": "scheduled", + "homeScore": null, + "awayScore": null + }, + { + "matchId": "wc2026-g01-m02", + "stage": "Group Stage", + "group": "Group B", + "homeTeam": "TBD B1", + "awayTeam": "TBD B2", + "kickoffUtc": "2026-06-12T19:00:00Z", + "venue": "BMO Field", + "city": "Toronto", + "status": "scheduled", + "homeScore": null, + "awayScore": null + }, + { + "matchId": "wc2026-g01-m03", + "stage": "Group Stage", + "group": "Group C", + "homeTeam": "TBD C1", + "awayTeam": "TBD C2", + "kickoffUtc": "2026-06-12T22:00:00Z", + "venue": "SoFi Stadium", + "city": "Los Angeles", + "status": "scheduled", + "homeScore": null, + "awayScore": null + }, + { + "matchId": "wc2026-r16-m01", + "stage": "Round of 32", + "group": "Knockout", + "homeTeam": "TBD R32-1", + "awayTeam": "TBD R32-2", + "kickoffUtc": "2026-06-28T19:00:00Z", + "venue": "TBD", + "city": "TBD", + "status": "scheduled", + "homeScore": null, + "awayScore": null + }, + { + "matchId": "wc2026-final", + "stage": "Final", + "group": "Knockout", + "homeTeam": "TBD Finalist 1", + "awayTeam": "TBD Finalist 2", + "kickoffUtc": "2026-07-19T19:00:00Z", + "venue": "MetLife Stadium", + "city": "New York/New Jersey", + "status": "scheduled", + "homeScore": null, + "awayScore": null + } + ] +} diff --git a/Sports/FootballVMProphet/data/odds.manual.example.csv b/Sports/FootballVMProphet/data/odds.manual.example.csv new file mode 100644 index 0000000..58b6b38 --- /dev/null +++ b/Sports/FootballVMProphet/data/odds.manual.example.csv @@ -0,0 +1,7 @@ +matchId,snapshotTime,bookmaker,market,selection,odds +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Home,1.95 +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Draw,3.40 +wc2026-g01-m01,2026-06-11T10:00:00+02:00,Manual,1X2,Away,4.20 +wc2026-g01-m01,2026-06-11T13:00:00+02:00,Manual,1X2,Home,1.82 +wc2026-g01-m01,2026-06-11T13:00:00+02:00,Manual,1X2,Draw,3.55 +wc2026-g01-m01,2026-06-11T13:00:00+02:00,Manual,1X2,Away,4.60 diff --git a/Sports/FootballVMProphet/data/watchlist.json b/Sports/FootballVMProphet/data/watchlist.json new file mode 100644 index 0000000..776d336 --- /dev/null +++ b/Sports/FootballVMProphet/data/watchlist.json @@ -0,0 +1,6 @@ +{ + "teams": [], + "markets": ["1X2", "OverUnder", "BTTS"], + "alertHoursBeforeKickoff": 6, + "notes": "Add teams after the official draw, for example: Norway, Brazil, England." +} diff --git a/Sports/FootballVMProphet/docs/Data-Model.md b/Sports/FootballVMProphet/docs/Data-Model.md new file mode 100644 index 0000000..5e6b0cc --- /dev/null +++ b/Sports/FootballVMProphet/docs/Data-Model.md @@ -0,0 +1,42 @@ +# VM Prophet data model + +## Fixture fields + +- `matchId` - stable local ID used to join fixtures, live scores, and odds. +- `stage` - Group Stage, Round of 32, Round of 16, Quarter-final, Semi-final, Final. +- `group` - group name or Knockout. +- `homeTeam` / `awayTeam` - team labels. Use TBD until official schedule is known. +- `kickoffUtc` - ISO timestamp in UTC. +- `venue` / `city` - stadium and city. +- `status` - scheduled, live, final, postponed, cancelled. +- `homeScore` / `awayScore` - null until known. + +## Live endpoint shape + +Optional `FREE_LIVE_ENDPOINT` should return: + +```json +{ + "matches": [ + { + "matchId": "wc2026-g01-m01", + "status": "LIVE", + "homeScore": 1, + "awayScore": 0, + "minute": 62, + "updatedAt": "2026-06-11T19:22:00Z" + } + ] +} +``` + +## Manual odds CSV + +- `matchId` +- `snapshotTime` +- `bookmaker` +- `market` +- `selection` +- `odds` + +Decimal odds only in v1. diff --git a/Sports/FootballVMProphet/docs/Free-Source-Notes.md b/Sports/FootballVMProphet/docs/Free-Source-Notes.md new file mode 100644 index 0000000..268c243 --- /dev/null +++ b/Sports/FootballVMProphet/docs/Free-Source-Notes.md @@ -0,0 +1,20 @@ +# Free source notes + +VM Prophet is intentionally free-first. That means: + +- The local fixture file is the source of truth. +- Live scores are optional and best-effort. +- Paid API keys are not required. +- Betting odds are manual CSV/import based in v1. +- Stale/broken data is shown in the dashboard instead of hidden. + +## Why not scrape betting sites? + +Scraping bookmakers is brittle and can violate terms of service. It is also dangerous for betting decisions because the site layout or anti-bot response may silently break the data. VM Prophet avoids that in v1. + +## Recommended workflow + +1. Keep fixtures updated manually from official sources. +2. Use optional free live endpoint only as a convenience. +3. Import odds snapshots manually when you care about a market. +4. Treat stale warnings as blockers for betting decisions. diff --git a/Sports/FootballVMProphet/package.json b/Sports/FootballVMProphet/package.json new file mode 100644 index 0000000..2108b77 --- /dev/null +++ b/Sports/FootballVMProphet/package.json @@ -0,0 +1,15 @@ +{ + "name": "football-vm-prophet", + "version": "0.1.0", + "description": "Free local FIFA World Cup 2026 dashboard with fixture, live-source health, cache, and manual odds tracking.", + "private": true, + "type": "module", + "scripts": { + "start": "node server/server.js", + "test": "node server/server.js --self-test" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/Sports/FootballVMProphet/server/server.js b/Sports/FootballVMProphet/server/server.js new file mode 100644 index 0000000..b00477b --- /dev/null +++ b/Sports/FootballVMProphet/server/server.js @@ -0,0 +1,147 @@ +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadFixtures } from './sources/local-fixtures.js'; +import { loadManualOdds } from './sources/manual-odds.js'; +import { fetchFreeLive } from './sources/free-live-source.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); +const port = Number(process.env.VM_PROPHET_PORT || 8787); +const timezone = process.env.VM_PROPHET_TIMEZONE || 'Europe/Oslo'; + +function jsonResponse(res, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store' + }); + res.end(payload); +} + +function textResponse(res, statusCode, contentType, body) { + res.writeHead(statusCode, { + 'Content-Type': contentType, + 'Cache-Control': 'no-store' + }); + res.end(body); +} + +function mergeLive(fixtures, liveResult) { + const liveById = new Map((liveResult.matches || []).map((m) => [m.matchId, m])); + return fixtures.matches.map((match) => { + const live = liveById.get(match.matchId); + if (!live) { + return { + ...match, + dataStatus: match.status === 'scheduled' ? 'fixture-only' : 'fixture-only', + liveSource: 'none' + }; + } + return { + ...match, + status: live.status || match.status, + homeScore: Number.isFinite(Number(live.homeScore)) ? Number(live.homeScore) : match.homeScore, + awayScore: Number.isFinite(Number(live.awayScore)) ? Number(live.awayScore) : match.awayScore, + minute: live.minute ?? match.minute, + liveUpdatedAt: live.updatedAt || liveResult.fetchedAt, + dataStatus: liveResult.ok ? 'live' : 'cached-or-broken', + liveSource: liveResult.source + }; + }); +} + +function buildDashboard() { + const fixtures = loadFixtures(rootDir); + const odds = loadManualOdds(rootDir); + const live = fetchFreeLive(rootDir); + const matches = mergeLive(fixtures, live); + const health = { + generatedAt: new Date().toISOString(), + timezone, + fixtures: fixtures.health, + live: live.health, + odds: odds.health, + warnings: [ + ...fixtures.health.warnings, + ...live.health.warnings, + ...odds.health.warnings + ] + }; + return { + tournament: fixtures.tournament, + timezone, + matches, + odds: odds.rows, + oddsSummary: odds.summary, + health + }; +} + +async function serveStatic(req, res) { + const urlPath = new URL(req.url, `http://localhost:${port}`).pathname; + const safePath = urlPath === '/' ? '/index.html' : urlPath; + const filePath = path.resolve(rootDir, 'web', `.${safePath}`); + if (!filePath.startsWith(path.resolve(rootDir, 'web'))) { + textResponse(res, 403, 'text/plain; charset=utf-8', 'Forbidden'); + return; + } + if (!existsSync(filePath)) { + textResponse(res, 404, 'text/plain; charset=utf-8', 'Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8' + }; + const body = await readFile(filePath); + textResponse(res, 200, types[ext] || 'application/octet-stream', body); +} + +export function createServer() { + return http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://localhost:${port}`); + if (url.pathname === '/api/dashboard') { + jsonResponse(res, 200, buildDashboard()); + return; + } + if (url.pathname === '/api/health') { + jsonResponse(res, 200, buildDashboard().health); + return; + } + await serveStatic(req, res); + } catch (error) { + jsonResponse(res, 500, { + ok: false, + error: error.message, + warning: 'VM Prophet failed to build a response. Check server console and data files.' + }); + } + }); +} + +if (process.argv.includes('--self-test')) { + const dashboard = buildDashboard(); + if (!dashboard.matches.length) { + throw new Error('Self-test failed: no fixtures loaded'); + } + if (!dashboard.health.fixtures.ok) { + throw new Error(`Self-test failed: fixture health not OK: ${dashboard.health.fixtures.warnings.join('; ')}`); + } + console.log(JSON.stringify({ ok: true, matches: dashboard.matches.length, warnings: dashboard.health.warnings }, null, 2)); +} else { + createServer().listen(port, () => { + console.log(`Football VM Prophet running at http://localhost:${port}`); + console.log(`Timezone: ${timezone}`); + if (!process.env.FREE_LIVE_ENDPOINT) { + console.log('FREE_LIVE_ENDPOINT not configured. Live data will be marked as disabled/fixture-only.'); + } + }); +} diff --git a/Sports/FootballVMProphet/server/sources/free-live-source.js b/Sports/FootballVMProphet/server/sources/free-live-source.js new file mode 100644 index 0000000..3b33281 --- /dev/null +++ b/Sports/FootballVMProphet/server/sources/free-live-source.js @@ -0,0 +1,62 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +function readCache(cachePath) { + if (!existsSync(cachePath)) return null; + try { + return JSON.parse(readFileSync(cachePath, 'utf8')); + } catch { + return null; + } +} + +export function fetchFreeLive(rootDir) { + const endpoint = process.env.FREE_LIVE_ENDPOINT || ''; + const cacheDir = path.join(rootDir, 'server', 'cache'); + const cachePath = path.join(cacheDir, 'free-live-cache.json'); + mkdirSync(cacheDir, { recursive: true }); + + const health = { + ok: false, + source: endpoint || 'not-configured', + fetchedAt: null, + cacheUsed: false, + warnings: [] + }; + + if (!endpoint) { + health.warnings.push('FREE_LIVE_ENDPOINT is not configured. Live scores are disabled; dashboard uses fixture/manual data only.'); + const cached = readCache(cachePath); + if (cached) { + health.cacheUsed = true; + health.warnings.push('Old live cache exists but endpoint is disabled. Cached live data is not trusted as live.'); + return { matches: cached.matches || [], source: 'cache-disabled', fetchedAt: cached.fetchedAt, ok: false, health }; + } + return { matches: [], source: 'disabled', fetchedAt: null, ok: false, health }; + } + + try { + const raw = execFileSync('curl', ['--fail', '--silent', '--show-error', '--max-time', '10', endpoint], { encoding: 'utf8' }); + const parsed = JSON.parse(raw); + const payload = { + matches: Array.isArray(parsed.matches) ? parsed.matches : [], + fetchedAt: new Date().toISOString(), + source: endpoint + }; + writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8'); + health.ok = true; + health.fetchedAt = payload.fetchedAt; + return { ...payload, ok: true, health }; + } catch (error) { + health.warnings.push(`FREE_LIVE_ENDPOINT failed: ${error.message}`); + const cached = readCache(cachePath); + if (cached) { + health.cacheUsed = true; + health.fetchedAt = cached.fetchedAt; + health.warnings.push(`Showing cached live data from ${cached.fetchedAt}. Treat as stale.`); + return { matches: cached.matches || [], source: 'cache-stale', fetchedAt: cached.fetchedAt, ok: false, health }; + } + return { matches: [], source: 'broken-no-cache', fetchedAt: null, ok: false, health }; + } +} diff --git a/Sports/FootballVMProphet/server/sources/local-fixtures.js b/Sports/FootballVMProphet/server/sources/local-fixtures.js new file mode 100644 index 0000000..5652757 --- /dev/null +++ b/Sports/FootballVMProphet/server/sources/local-fixtures.js @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export function loadFixtures(rootDir) { + const fixturePath = path.join(rootDir, 'data', 'fixtures.worldcup2026.json'); + const health = { + ok: true, + source: fixturePath, + loadedAt: new Date().toISOString(), + warnings: [] + }; + try { + const raw = readFileSync(fixturePath, 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.matches)) { + throw new Error('fixtures.worldcup2026.json must contain a matches array'); + } + for (const match of parsed.matches) { + if (!match.matchId || !match.kickoffUtc || !match.homeTeam || !match.awayTeam) { + health.warnings.push(`Fixture ${match.matchId || 'unknown'} is missing required fields.`); + } + } + if (parsed.dataQuality === 'seed') { + health.warnings.push('Fixture file is seed data. Replace with official FIFA 2026 fixtures when available.'); + } + return { + tournament: parsed.tournament || { name: 'FIFA World Cup 2026' }, + matches: parsed.matches, + health + }; + } catch (error) { + health.ok = false; + health.warnings.push(`Fixture load failed: ${error.message}`); + return { + tournament: { name: 'FIFA World Cup 2026' }, + matches: [], + health + }; + } +} diff --git a/Sports/FootballVMProphet/server/sources/manual-odds.js b/Sports/FootballVMProphet/server/sources/manual-odds.js new file mode 100644 index 0000000..5e6bcb7 --- /dev/null +++ b/Sports/FootballVMProphet/server/sources/manual-odds.js @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +function parseCsvLine(line) { + const values = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + values.push(current.trim()); + current = ''; + } else { + current += char; + } + } + values.push(current.trim()); + return values; +} + +function impliedProbability(decimalOdds) { + const odds = Number(decimalOdds); + if (!Number.isFinite(odds) || odds <= 1) return null; + return Number((100 / odds).toFixed(2)); +} + +export function loadManualOdds(rootDir) { + const oddsPath = path.join(rootDir, 'data', 'odds.manual.csv'); + const examplePath = path.join(rootDir, 'data', 'odds.manual.example.csv'); + const health = { + ok: true, + source: oddsPath, + loadedAt: new Date().toISOString(), + mode: 'manual', + warnings: [] + }; + const sourcePath = existsSync(oddsPath) ? oddsPath : examplePath; + if (!existsSync(oddsPath)) { + health.warnings.push('No data/odds.manual.csv found. Loaded example odds only; betting board is sample/manual mode.'); + } + try { + const raw = readFileSync(sourcePath, 'utf8').trim(); + if (!raw) return { rows: [], summary: [], health }; + const lines = raw.split(/\r?\n/).filter(Boolean); + const headers = parseCsvLine(lines.shift()); + const rows = lines.map((line) => { + const values = parseCsvLine(line); + const row = Object.fromEntries(headers.map((header, index) => [header, values[index] || ''])); + row.odds = Number(row.odds); + row.impliedProbability = impliedProbability(row.odds); + return row; + }); + const grouped = new Map(); + for (const row of rows) { + const key = `${row.matchId}|${row.market}|${row.selection}`; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(row); + } + const summary = []; + for (const [key, group] of grouped.entries()) { + group.sort((a, b) => new Date(a.snapshotTime) - new Date(b.snapshotTime)); + const first = group[0]; + const last = group[group.length - 1]; + summary.push({ + key, + matchId: last.matchId, + market: last.market, + selection: last.selection, + bookmaker: last.bookmaker, + firstOdds: first.odds, + latestOdds: last.odds, + oddsMovement: Number((last.odds - first.odds).toFixed(3)), + impliedProbability: last.impliedProbability, + latestSnapshot: last.snapshotTime, + sourceMode: existsSync(oddsPath) ? 'manual' : 'example' + }); + } + return { rows, summary, health }; + } catch (error) { + health.ok = false; + health.warnings.push(`Manual odds load failed: ${error.message}`); + return { rows: [], summary: [], health }; + } +} diff --git a/Sports/FootballVMProphet/web/app.js b/Sports/FootballVMProphet/web/app.js new file mode 100644 index 0000000..f765e64 --- /dev/null +++ b/Sports/FootballVMProphet/web/app.js @@ -0,0 +1,180 @@ +let dashboard = null; + +const els = { + healthBanner: document.getElementById('healthBanner'), + matches: document.getElementById('matches'), + oddsBoard: document.getElementById('oddsBoard'), + healthDetails: document.getElementById('healthDetails'), + stats: document.getElementById('stats'), + lastRefresh: document.getElementById('lastRefresh'), + viewFilter: document.getElementById('viewFilter'), + teamFilter: document.getElementById('teamFilter'), + stageFilter: document.getElementById('stageFilter'), + venueFilter: document.getElementById('venueFilter'), + searchFilter: document.getElementById('searchFilter'), + refreshButton: document.getElementById('refreshButton') +}; + +function asDate(value) { + return value ? new Date(value) : null; +} + +function fmtDate(value, timezone) { + const date = asDate(value); + if (!date || Number.isNaN(date.getTime())) return 'Unknown'; + return new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + dateStyle: 'medium', + timeStyle: 'short' + }).format(date); +} + +function countdown(value) { + const date = asDate(value); + if (!date) return 'Unknown'; + const ms = date.getTime() - Date.now(); + if (ms < 0) return 'Started / past'; + const hours = Math.floor(ms / 36e5); + const days = Math.floor(hours / 24); + const remHours = hours % 24; + const minutes = Math.floor((ms % 36e5) / 60000); + return `${days}d ${remHours}h ${minutes}m`; +} + +function isToday(value) { + const date = asDate(value); + const now = new Date(); + return date && date.toDateString() === now.toDateString(); +} + +function statusClass(status) { + const s = String(status || '').toLowerCase(); + if (s.includes('live')) return 'live'; + if (s.includes('final') || s.includes('past')) return 'past'; + return 'upcoming'; +} + +function renderHealth() { + const warnings = dashboard.health.warnings || []; + const broken = warnings.length > 0 || !dashboard.health.fixtures.ok || !dashboard.health.live.ok; + els.healthBanner.className = broken ? 'health-banner broken' : 'health-banner ok'; + els.healthBanner.textContent = broken + ? `DATA WARNING: ${warnings[0] || 'One or more sources are not healthy.'}` + : 'DATA OK: fixtures loaded and no source warnings.'; + els.healthDetails.textContent = JSON.stringify(dashboard.health, null, 2); +} + +function getFilteredMatches() { + const view = els.viewFilter.value; + const team = els.teamFilter.value.trim().toLowerCase(); + const stage = els.stageFilter.value.trim().toLowerCase(); + const venue = els.venueFilter.value.trim().toLowerCase(); + const search = els.searchFilter.value.trim().toLowerCase(); + const now = Date.now(); + const dueMs = 24 * 36e5; + + return dashboard.matches.filter((match) => { + const kickoff = asDate(match.kickoffUtc)?.getTime() || 0; + const status = String(match.status || '').toLowerCase(); + const haystack = `${match.homeTeam} ${match.awayTeam} ${match.stage} ${match.group || ''} ${match.venue || ''} ${match.city || ''} ${match.dataStatus}`.toLowerCase(); + + if (view === 'today' && !isToday(match.kickoffUtc)) return false; + if (view === 'live' && !status.includes('live')) return false; + if (view === 'upcoming' && kickoff <= now) return false; + if (view === 'past' && kickoff >= now && !status.includes('final')) return false; + if (view === 'due' && !(kickoff > now && kickoff - now <= dueMs)) return false; + if (view === 'stale' && !['fixture-only', 'cached-or-broken'].includes(match.dataStatus)) return false; + if (team && !`${match.homeTeam} ${match.awayTeam}`.toLowerCase().includes(team)) return false; + if (stage && !String(match.stage || '').toLowerCase().includes(stage)) return false; + if (venue && !`${match.venue || ''} ${match.city || ''}`.toLowerCase().includes(venue)) return false; + if (search && !haystack.includes(search)) return false; + return true; + }).sort((a, b) => new Date(a.kickoffUtc) - new Date(b.kickoffUtc)); +} + +function renderStats(matches) { + const now = Date.now(); + const live = dashboard.matches.filter((m) => String(m.status).toLowerCase().includes('live')).length; + const upcoming = dashboard.matches.filter((m) => new Date(m.kickoffUtc).getTime() > now).length; + const due = dashboard.matches.filter((m) => { + const kickoff = new Date(m.kickoffUtc).getTime(); + return kickoff > now && kickoff - now <= 24 * 36e5; + }).length; + const stale = dashboard.matches.filter((m) => ['fixture-only', 'cached-or-broken'].includes(m.dataStatus)).length; + els.stats.innerHTML = [ + ['Shown', matches.length], ['Live', live], ['Upcoming', upcoming], ['Due 24h', due], ['Stale/fixture-only', stale] + ].map(([label, value]) => `
FIFA World Cup 2026 free local dashboard
+