feat: add free Football VM Prophet dashboard#1
Conversation
📝 WalkthroughWalkthroughThis pull request introduces Football VM Prophet, a local command-line dashboard for FIFA World Cup 2026 fixtures, live-score health, match filtering, and manual odds tracking. The project includes complete documentation, seed fixture data, backend server with data loaders, and a responsive web UI. ChangesFootball VM Prophet Dashboard
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (2)
Sports/FootballVMProphet/data/watchlist.json (1)
1-7: Verify watchlist.json integration: file exists but is not loaded by the backend.The watchlist.json file is included in the project and listed in the README file layout (line 104). However, the
buildDashboard()function inserver.jsdoes not load or use this configuration. Either:
- This is intended as future work: Watchlist.json is a placeholder for a v2 feature and should be documented as such, or removed from the current PR.
- Integration is incomplete: Watchlist.json should be loaded and its
teamsandmarketsused to drive backend filtering/alerts, but the code is missing.This needs clarification to avoid confusion about whether watchlist configuration is actively used.
Can you clarify the intended role of watchlist.json in this PR? If it's not yet integrated into the backend, I'd recommend either removing it or documenting it as a future feature placeholder.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sports/FootballVMProphet/data/watchlist.json` around lines 1 - 7, Decide whether watchlist.json is a placeholder or required: if placeholder, remove the file or add a clear note in the README and the top of watchlist.json stating "placeholder / v2 feature - not used by backend"; if it should be active, update server.js to load watchlist.json inside buildDashboard() (or a helper called by buildDashboard), parse the "teams" and "markets" arrays and apply them to the backend filtering/alert logic so buildDashboard uses those filters when constructing dashboard data; reference watchlist.json and the buildDashboard() function when making the change.Sports/FootballVMProphet/README.md (1)
19-23: 💤 Low valueReduce repetitive "It does not" sentence starters.
Lines 19–23 use parallel construction with "It does not..." four times in a row. While intentional, this reads as repetitive. Consider rewording one or two lines for variety.
💡 Example rewording
- - It does not place bets. - - It does not guarantee predictions. - - It does not hide stale data behind a "live" label. + - Bets are not placed automatically. + - Predictions are not guaranteed. - - 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. + - No paid APIs required. + - Official World Cup 2026 fixture data is not included; the seed file must be replaced when the schedule is confirmed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sports/FootballVMProphet/README.md` around lines 19 - 23, The README contains four consecutive lines starting with the repeated phrase "It does not" making the copy feel redundant; revise one or two of those lines (the lines containing the repeated "It does not" sentences) to vary sentence starters and rhythm while preserving the same meaning—for example, convert one to a positive-framed clause or use a different verb phrase ("Does not place bets" → "No betting is performed" or "Predictions are not guaranteed" → "Predictions are informational only"); update the sentences in-place so the bullet list still conveys the same constraints but with less repetitive phrasing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sports/FootballVMProphet/server/server.js`:
- Around line 87-89: The current string-prefix guard for filePath (built from
rootDir and safePath) is insecure; resolve the real paths and use path.relative
to ensure the requested file is inside the web root: compute const webRootReal =
await fs.promises.realpath(path.resolve(rootDir, 'web')) and const fileReal =
await fs.promises.realpath(path.resolve(rootDir, 'web', `.${safePath}`)), then
get rel = path.relative(webRootReal, fileReal) and reject the request (call
textResponse(res, 403, ... 'Forbidden')) if rel.startsWith('..') or
path.isAbsolute(rel); update the code paths that reference
filePath/safePath/rootDir to use these checks so symlinks and sibling-directory
tricks cannot escape the web root.
In `@Sports/FootballVMProphet/server/sources/free-live-source.js`:
- Around line 39-50: fetchFreeLive currently blocks the server using
execFileSync('curl', ...) and passes endpoint unsanitized to curl; change it to
an asynchronous, non-blocking fetch with proper URL validation/whitelist and a
safe argument separator instead of passing raw endpoint to a shell command.
Specifically: replace the synchronous execFileSync call in fetchFreeLive with an
async HTTP client (fetch/axios or execFile with callbacks/promises) so requests
are served from cache immediately; implement a background refresher that updates
the cache on a TTL interval and on startup (refer to fetchFreeLive,
writeFileSync usage, health.ok and health.fetchedAt updates), validate
FREE_LIVE_ENDPOINT with URL parsing/whitelist before any network call and never
pass an untrusted string as a CLI argument (if you must keep curl, include '--'
and an explicit validation step), and ensure buildDashboard() and /api/health
serve cached payload and reflect TTL/health state updated by the background
task.
- Line 40: Validate the endpoint's scheme and terminate curl options: before
calling execFileSync, construct a URL from the FREE_LIVE_ENDPOINT/endpoint
variable using the URL constructor, and if url.protocol is not 'http:' or
'https:' throw an error so the existing try/catch handles invalid endpoints;
then call execFileSync('curl',
['--fail','--silent','--show-error','--max-time','10','--', endpoint], {
encoding: 'utf8' }) (note the added '--' before endpoint) so values beginning
with '-' cannot be interpreted as curl flags.
In `@Sports/FootballVMProphet/server/sources/local-fixtures.js`:
- Around line 18-22: The current validation only checks presence of
match.matchId and pushes a warning to health.warnings; instead detect duplicate
matchId values in parsed.matches and treat them as errors: iterate
parsed.matches, maintain a seenIds Set, and when encountering a match.matchId
that is already in seenIds push a descriptive error to health.errors (not
health.warnings) referencing the duplicate matchId and the offending item (use
match.matchId or index) so the file is rejected; keep the existing presence
checks but move any duplicate-detection logic into the same loop that inspects
parsed.matches (so mergeLive() and web/app.js won’t later collapse/choose the
wrong fixture).
In `@Sports/FootballVMProphet/server/sources/manual-odds.js`:
- Around line 47-54: The CSV header is not validated before building rows
(headers variable used in the rows mapping), so typos/missing fields produce bad
data; update the loader that calls parseCsvLine and builds rows to assert that
headers includes the required fields
['matchId','snapshotTime','bookmaker','market','selection','odds'] (or
throw/return an error) before mapping lines to rows, and ensure any missing
header causes an immediate failure (not silent empty values), so downstream uses
of rows, impliedProbability, and dashboard.oddsSummary only receive validated
data.
- Around line 48-79: The parsing pipeline (parseCsvLine -> rows -> grouped ->
summary) does not validate required fields so malformed lines produce bad
buckets and NaN movements; update the rows construction to validate each parsed
row before grouping: after building row and converting row.odds and
row.snapshotTime, check that row.matchId, row.market, row.selection and a valid
Date(snapshotTime) exist and that Number.isFinite(row.odds); for any rejected
row increment or push a health warning (use the module's existing
logger/health-reporting mechanism or an array named e.g. manualOddsWarnings)
with the original line and reason, and skip adding that row to rows so
grouped/summary only processes valid entries; ensure
impliedProbability(row.odds) is only called for validated numeric odds.
In `@Sports/FootballVMProphet/web/app.js`:
- Around line 44-47: isToday currently compares dates using the viewer's local
timezone which can mismatch the UI that formats kickoff times with
dashboard.timezone; update isToday to interpret the input value in
dashboard.timezone (use the same timezone conversion helper used elsewhere in
the app) and compare the date portions in that timezone instead of using
date.toDateString(); locate and modify the isToday function and any call sites
(e.g., the check used around line 81) to use the timezone-aware date result so
"today" aligns with dashboard.timezone.
- Around line 32-41: The countdown function can receive an Invalid Date from
asDate (e.g., asDate('bad-value')), so update countdown to guard against that by
checking date validity (e.g., isNaN(date.getTime()) or date.toString() ===
'Invalid Date') right after calling asDate; if invalid, return 'Unknown' and
only compute ms, hours, days, remHours and minutes when date is valid—adjust
logic in the countdown function to perform this validation before any time
arithmetic.
- Around line 164-180: The loadDashboard function does not check response.ok or
handle JSON parsing errors and the refresh button calls loadDashboard without
handling rejections; update loadDashboard to check response.ok and throw a
descriptive error when the fetch fails, wrap the response.json() in try/catch to
handle bad payloads, and ensure on any error you set els.healthBanner.className
to 'health-banner broken' and set els.healthBanner.textContent to a clear
message; also change the els.refreshButton click handler to call
loadDashboard().catch(...) (or otherwise handle the returned promise) so manual
refresh failures are caught and invoke the same banner update path; reference
loadDashboard, els.refreshButton, els.healthBanner, render, and renderMatches
when applying the fix.
In `@Sports/FootballVMProphet/web/styles.css`:
- Line 14: The font-family declaration on the body rule uses an unquoted
multi-word family name; update the font-family in the body selector (the body {
... font-family: ... } rule) to quote "Segoe UI" (use double quotes) so it
satisfies the font-family-name-quotes Stylelint rule, e.g. change Segoe UI to
"Segoe UI" while keeping the rest of the stack unchanged.
---
Nitpick comments:
In `@Sports/FootballVMProphet/data/watchlist.json`:
- Around line 1-7: Decide whether watchlist.json is a placeholder or required:
if placeholder, remove the file or add a clear note in the README and the top of
watchlist.json stating "placeholder / v2 feature - not used by backend"; if it
should be active, update server.js to load watchlist.json inside
buildDashboard() (or a helper called by buildDashboard), parse the "teams" and
"markets" arrays and apply them to the backend filtering/alert logic so
buildDashboard uses those filters when constructing dashboard data; reference
watchlist.json and the buildDashboard() function when making the change.
In `@Sports/FootballVMProphet/README.md`:
- Around line 19-23: The README contains four consecutive lines starting with
the repeated phrase "It does not" making the copy feel redundant; revise one or
two of those lines (the lines containing the repeated "It does not" sentences)
to vary sentence starters and rhythm while preserving the same meaning—for
example, convert one to a positive-framed clause or use a different verb phrase
("Does not place bets" → "No betting is performed" or "Predictions are not
guaranteed" → "Predictions are informational only"); update the sentences
in-place so the bullet list still conveys the same constraints but with less
repetitive phrasing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ea849b13-326e-4a90-8210-a3a7dd9fc5bc
⛔ Files ignored due to path filters (1)
Sports/FootballVMProphet/data/odds.manual.example.csvis excluded by!**/*.csv
📒 Files selected for processing (13)
Sports/FootballVMProphet/README.mdSports/FootballVMProphet/data/fixtures.worldcup2026.jsonSports/FootballVMProphet/data/watchlist.jsonSports/FootballVMProphet/docs/Data-Model.mdSports/FootballVMProphet/docs/Free-Source-Notes.mdSports/FootballVMProphet/package.jsonSports/FootballVMProphet/server/server.jsSports/FootballVMProphet/server/sources/free-live-source.jsSports/FootballVMProphet/server/sources/local-fixtures.jsSports/FootballVMProphet/server/sources/manual-odds.jsSports/FootballVMProphet/web/app.jsSports/FootballVMProphet/web/index.htmlSports/FootballVMProphet/web/styles.css
| const filePath = path.resolve(rootDir, 'web', `.${safePath}`); | ||
| if (!filePath.startsWith(path.resolve(rootDir, 'web'))) { | ||
| textResponse(res, 403, 'text/plain; charset=utf-8', 'Forbidden'); |
There was a problem hiding this comment.
The static-file traversal guard is bypassable.
This startsWith() check is string-based, so sibling paths like <root>/web-evil/... still pass because the string begins with <root>/web. It also does not protect against symlinks inside web/ that point outside the directory. That makes the static handler capable of serving files outside Sports/FootballVMProphet/web. Use path.relative() against a realpath’d web root/file instead of raw string prefix checks.
Suggested fix
-import { existsSync } from 'node:fs';
+import { existsSync, realpathSync } from 'node:fs';
@@
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)) {
+ const webRoot = realpathSync(path.resolve(rootDir, 'web'));
+ const filePath = path.resolve(webRoot, `.${safePath}`);
+ if (!existsSync(filePath)) {
textResponse(res, 404, 'text/plain; charset=utf-8', 'Not found');
return;
}
- const ext = path.extname(filePath).toLowerCase();
+ const realFilePath = realpathSync(filePath);
+ const relativePath = path.relative(webRoot, realFilePath);
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
+ textResponse(res, 403, 'text/plain; charset=utf-8', 'Forbidden');
+ return;
+ }
+ const ext = path.extname(realFilePath).toLowerCase();
@@
- const body = await readFile(filePath);
+ const body = await readFile(realFilePath);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/server/server.js` around lines 87 - 89, The current
string-prefix guard for filePath (built from rootDir and safePath) is insecure;
resolve the real paths and use path.relative to ensure the requested file is
inside the web root: compute const webRootReal = await
fs.promises.realpath(path.resolve(rootDir, 'web')) and const fileReal = await
fs.promises.realpath(path.resolve(rootDir, 'web', `.${safePath}`)), then get rel
= path.relative(webRootReal, fileReal) and reject the request (call
textResponse(res, 403, ... 'Forbidden')) if rel.startsWith('..') or
path.isAbsolute(rel); update the code paths that reference
filePath/safePath/rootDir to use these checks so symlinks and sibling-directory
tricks cannot escape the web root.
| 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 }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n[1] fetchFreeLive implementation:\n'
sed -n '39,50p' Sports/FootballVMProphet/server/sources/free-live-source.js
printf '\n[2] request handlers calling buildDashboard():\n'
sed -n '107,119p' Sports/FootballVMProphet/server/server.js
printf '\n[3] buildDashboard calling fetchFreeLive():\n'
sed -n '57,61p' Sports/FootballVMProphet/server/server.jsRepository: Thugney/eriteach-scripts
Length of output: 1345
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== free-live-source.js (full relevant context) ==="
nl -ba Sports/FootballVMProphet/server/sources/free-live-source.js | sed -n '1,200p'
echo
echo "=== server.js (full relevant context for handlers/buildDashboard) ==="
nl -ba Sports/FootballVMProphet/server/server.js | sed -n '1,220p'
echo
echo "=== Grep for FREE_LIVE_ENDPOINT / cache usage ==="
rg -n --hidden --no-ignore-vcs "FREE_LIVE|endpoint|cachePath|cache|fetchedAt|curl" Sports/FootballVMProphet/server/sources/free-live-source.js Sports/FootballVMProphet/server/server.jsRepository: Thugney/eriteach-scripts
Length of output: 181
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== File sizes ==="
wc -l Sports/FootballVMProphet/server/sources/free-live-source.js
wc -l Sports/FootballVMProphet/server/server.js
echo
echo "=== free-live-source.js: lines 1-120 ==="
cat -n Sports/FootballVMProphet/server/sources/free-live-source.js | sed -n '1,120p'
echo
echo "=== free-live-source.js: lines 120-end (last 80) ==="
tail -n 80 Sports/FootballVMProphet/server/sources/free-live-source.js
echo
echo "=== server.js: lines 1-220 ==="
cat -n Sports/FootballVMProphet/server/server.js | sed -n '1,220p'
echo
echo "=== server.js: any other references to buildDashboard / /api/dashboard / /api/health ==="
rg -n "buildDashboard\\(|/api/dashboard|/api/health|fetchFreeLive\\(" Sports/FootballVMProphet/server/server.js
echo
echo "=== free-live-source.js: locate FREE_LIVE_ENDPOINT / endpoint / cachePath definitions ==="
rg -n "FREE_LIVE_ENDPOINT|endpoint|cachePath|writeFileSync\\(|execFileSync\\('curl'|fetchFreeLive\\(" Sports/FootballVMProphet/server/sources/free-live-source.jsRepository: Thugney/eriteach-scripts
Length of output: 12985
Stop blocking /api/dashboard and /api/health with synchronous curl, and harden FREE_LIVE_ENDPOINT usage
- Availability/Stability:
buildDashboard()callsfetchFreeLive(), which usesexecFileSync('curl', ... '--max-time', '10', endpoint); both/api/dashboardand/api/healthhitbuildDashboard()per request, so one slow/free endpoint can stall the server thread up to 10s. Sports/FootballVMProphet/server/sources/free-live-source.js:39-61, Sports/FootballVMProphet/server/server.js:57-82,107-118 - Security:
endpointis passed to curl without a--argument terminator, so a misconfiguredFREE_LIVE_ENDPOINTstarting with-can be interpreted as curl options (option-injection). Add--and validate/whitelist the URL format. Sports/FootballVMProphet/server/sources/free-live-source.js:39-50 [security]
Move the fetch off the request path (background refresh) and serve cached data with a TTL (current cache is only “stale” fallback, not TTL-based).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/server/sources/free-live-source.js` around lines 39
- 50, fetchFreeLive currently blocks the server using execFileSync('curl', ...)
and passes endpoint unsanitized to curl; change it to an asynchronous,
non-blocking fetch with proper URL validation/whitelist and a safe argument
separator instead of passing raw endpoint to a shell command. Specifically:
replace the synchronous execFileSync call in fetchFreeLive with an async HTTP
client (fetch/axios or execFile with callbacks/promises) so requests are served
from cache immediately; implement a background refresher that updates the cache
on a TTL interval and on startup (refer to fetchFreeLive, writeFileSync usage,
health.ok and health.fetchedAt updates), validate FREE_LIVE_ENDPOINT with URL
parsing/whitelist before any network call and never pass an untrusted string as
a CLI argument (if you must keep curl, include '--' and an explicit validation
step), and ensure buildDashboard() and /api/health serve cached payload and
reflect TTL/health state updated by the background task.
| } | ||
|
|
||
| try { | ||
| const raw = execFileSync('curl', ['--fail', '--silent', '--show-error', '--max-time', '10', endpoint], { encoding: 'utf8' }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\nCurrent curl invocation:\n'
sed -n '39,41p' Sports/FootballVMProphet/server/sources/free-live-source.jsRepository: Thugney/eriteach-scripts
Length of output: 268
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="Sports/FootballVMProphet/server/sources/free-live-source.js"
echo "---- file context (1-120) ----"
sed -n '1,120p' "$FILE"
echo "---- find FREE_LIVE_ENDPOINT usage in repo ----"
rg -n "FREE_LIVE_ENDPOINT" -S .
echo "---- find curl / execFileSync usage in repo ----"
rg -n "execFileSync\\('curl'|\\bcurl\\b" -S Sports/FootballVMProphet/server/sources/free-live-source.js Sports/FootballVMProphet/server/sources
echo "---- exact lines around invocation (30-60) ----"
sed -n '30,60p' "$FILE"Repository: Thugney/eriteach-scripts
Length of output: 5673
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE1="Sports/FootballVMProphet/server/server.js"
FILE2="Sports/FootballVMProphet/docs/Data-Model.md"
FILE3="Sports/FootballVMProphet/README.md"
echo "---- server.js context around FREE_LIVE_ENDPOINT (120-190) ----"
sed -n '120,190p' "$FILE1"
echo "---- Data-Model.md FREE_LIVE_ENDPOINT (1-80) ----"
sed -n '1,80p' "$FILE2"
echo "---- README.md FREE_LIVE_ENDPOINT (35-110) ----"
sed -n '35,110p' "$FILE3"Repository: Thugney/eriteach-scripts
Length of output: 4054
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "fetchFreeLive\\(" -S Sports/FootballVMProphet/serverRepository: Thugney/eriteach-scripts
Length of output: 256
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="Sports/FootballVMProphet/server/server.js"
sed -n '1,120p' "$FILE"Repository: Thugney/eriteach-scripts
Length of output: 3953
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="Sports/FootballVMProphet/server/server.js"
sed -n '120,220p' "$FILE"Repository: Thugney/eriteach-scripts
Length of output: 1085
Terminate curl options before the endpoint and restrict the scheme.
FREE_LIVE_ENDPOINT is passed as the final curl argument without a --, so a value starting with - can be interpreted as additional curl flags (option injection). Also, there’s no validation that the endpoint is only http/https, so non-web schemes could be fetched (SSRF/LFR-style risk). Update Sports/FootballVMProphet/server/sources/free-live-source.js so curl receives -- before the URL and reject non-http(s) protocols, while keeping invalid-endpoint handling inside the existing try/catch behavior to preserve the current cache/fallback flow.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/server/sources/free-live-source.js` at line 40,
Validate the endpoint's scheme and terminate curl options: before calling
execFileSync, construct a URL from the FREE_LIVE_ENDPOINT/endpoint variable
using the URL constructor, and if url.protocol is not 'http:' or 'https:' throw
an error so the existing try/catch handles invalid endpoints; then call
execFileSync('curl', ['--fail','--silent','--show-error','--max-time','10','--',
endpoint], { encoding: 'utf8' }) (note the added '--' before endpoint) so values
beginning with '-' cannot be interpreted as curl flags.
| 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.`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Enforce unique matchId values here, not just presence.
matchId is the join key for both live data and odds, but this validation only checks that it exists. If the fixture file contains duplicates, mergeLive() in Sports/FootballVMProphet/server/server.js collapses live rows into a Map by matchId, and web/app.js resolves odds rows back to the first matching fixture, so the dashboard can silently show the wrong teams/scores for one of the duplicates. This should be treated as invalid fixture data instead of a warning-only condition.
Suggested fix
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');
}
+ const seenMatchIds = new Set();
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 (match.matchId) {
+ if (seenMatchIds.has(match.matchId)) {
+ throw new Error(`Duplicate fixture matchId: ${match.matchId}`);
+ }
+ seenMatchIds.add(match.matchId);
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/server/sources/local-fixtures.js` around lines 18 -
22, The current validation only checks presence of match.matchId and pushes a
warning to health.warnings; instead detect duplicate matchId values in
parsed.matches and treat them as errors: iterate parsed.matches, maintain a
seenIds Set, and when encountering a match.matchId that is already in seenIds
push a descriptive error to health.errors (not health.warnings) referencing the
duplicate matchId and the offending item (use match.matchId or index) so the
file is rejected; keep the existing presence checks but move any
duplicate-detection logic into the same loop that inspects parsed.matches (so
mergeLive() and web/app.js won’t later collapse/choose the wrong fixture).
| 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; | ||
| }); |
There was a problem hiding this comment.
Validate the CSV header before building rows.
This trusts whatever header row is present, but matchId, snapshotTime, bookmaker, market, selection, and odds are required by the documented CSV contract. A typo like matchID or an incomplete header silently produces empty keys, NaN odds, and merged summaries under '||', which then flow straight into dashboard.oddsSummary and the UI. Fail fast on missing headers instead of treating the file as loaded successfully.
Suggested fix
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 requiredHeaders = ['matchId', 'snapshotTime', 'bookmaker', 'market', 'selection', 'odds'];
+ const missingHeaders = requiredHeaders.filter((header) => !headers.includes(header));
+ if (missingHeaders.length) {
+ throw new Error(`Manual odds CSV is missing required headers: ${missingHeaders.join(', ')}`);
+ }
const rows = lines.map((line) => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/server/sources/manual-odds.js` around lines 47 - 54,
The CSV header is not validated before building rows (headers variable used in
the rows mapping), so typos/missing fields produce bad data; update the loader
that calls parseCsvLine and builds rows to assert that headers includes the
required fields
['matchId','snapshotTime','bookmaker','market','selection','odds'] (or
throw/return an error) before mapping lines to rows, and ensure any missing
header causes an immediate failure (not silent empty values), so downstream uses
of rows, impliedProbability, and dashboard.oddsSummary only receive validated
data.
| 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`; |
There was a problem hiding this comment.
Guard countdown() against invalid timestamps.
asDate('bad-value') returns an Invalid Date object, so this path currently renders NaNd NaNh NaNm instead of a safe fallback.
Suggested fix
function countdown(value) {
const date = asDate(value);
- if (!date) return 'Unknown';
+ if (!date || Number.isNaN(date.getTime())) return 'Unknown';
const ms = date.getTime() - Date.now();
if (ms < 0) return 'Started / past';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 countdown(value) { | |
| const date = asDate(value); | |
| if (!date || Number.isNaN(date.getTime())) 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`; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/web/app.js` around lines 32 - 41, The countdown
function can receive an Invalid Date from asDate (e.g., asDate('bad-value')), so
update countdown to guard against that by checking date validity (e.g.,
isNaN(date.getTime()) or date.toString() === 'Invalid Date') right after calling
asDate; if invalid, return 'Unknown' and only compute ms, hours, days, remHours
and minutes when date is valid—adjust logic in the countdown function to perform
this validation before any time arithmetic.
| function isToday(value) { | ||
| const date = asDate(value); | ||
| const now = new Date(); | ||
| return date && date.toDateString() === now.toDateString(); |
There was a problem hiding this comment.
Use dashboard.timezone for the “Today” filter too.
The UI formats kickoff times with dashboard.timezone, but isToday() compares toDateString() in the viewer’s local timezone. For matches near midnight, Line 81 can file a match under the wrong day relative to the date shown on screen.
Suggested fix
-function isToday(value) {
+function isToday(value, timezone) {
const date = asDate(value);
- const now = new Date();
- return date && date.toDateString() === now.toDateString();
+ if (!date || Number.isNaN(date.getTime())) return false;
+ const fmt = new Intl.DateTimeFormat('en-CA', {
+ timeZone: timezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ });
+ return fmt.format(date) === fmt.format(new Date());
}
@@
- if (view === 'today' && !isToday(match.kickoffUtc)) return false;
+ if (view === 'today' && !isToday(match.kickoffUtc, dashboard.timezone)) return false;Also applies to: 81-81
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/web/app.js` around lines 44 - 47, isToday currently
compares dates using the viewer's local timezone which can mismatch the UI that
formats kickoff times with dashboard.timezone; update isToday to interpret the
input value in dashboard.timezone (use the same timezone conversion helper used
elsewhere in the app) and compare the date portions in that timezone instead of
using date.toDateString(); locate and modify the isToday function and any call
sites (e.g., the check used around line 81) to use the timezone-aware date
result so "today" aligns with dashboard.timezone.
| els.matches.innerHTML = matches.map((match) => { | ||
| const score = Number.isFinite(match.homeScore) && Number.isFinite(match.awayScore) | ||
| ? `${match.homeScore} - ${match.awayScore}` | ||
| : 'vs'; | ||
| const dataTag = match.dataStatus === 'live' ? 'LIVE DATA' : String(match.dataStatus || 'unknown').toUpperCase(); | ||
| return `<article class="match-card ${statusClass(match.status)}"> | ||
| <div class="match-main"> | ||
| <div class="teams"><span>${match.homeTeam}</span><b>${score}</b><span>${match.awayTeam}</span></div> | ||
| <div class="meta">${match.stage}${match.group ? ` / ${match.group}` : ''} · ${match.venue || 'Venue TBD'}${match.city ? `, ${match.city}` : ''}</div> | ||
| <div class="meta">Kickoff: ${fmtDate(match.kickoffUtc, dashboard.timezone)} · Countdown: ${countdown(match.kickoffUtc)}</div> | ||
| </div> | ||
| <div class="badges"> | ||
| <span class="badge ${statusClass(match.status)}">${match.status}</span> | ||
| <span class="badge data">${dataTag}</span> | ||
| ${match.minute ? `<span class="badge">${match.minute}'</span>` : ''} | ||
| </div> | ||
| </article>`; | ||
| }).join(''); |
There was a problem hiding this comment.
Stop injecting fixture/odds fields into innerHTML unsafely.
match.homeTeam, match.venue, row.market, row.selection, and similar fields come from local fixture JSON, manual CSV, and the optional live-source pipeline. Rendering them straight into template strings makes the dashboard vulnerable to DOM XSS if any source contains markup.
Also applies to: 142-154
| async function loadDashboard() { | ||
| els.healthBanner.textContent = 'Loading...'; | ||
| const response = await fetch('/api/dashboard', { cache: 'no-store' }); | ||
| dashboard = await response.json(); | ||
| render(); | ||
| } | ||
|
|
||
| ['change', 'input'].forEach((eventName) => { | ||
| [els.viewFilter, els.teamFilter, els.stageFilter, els.venueFilter, els.searchFilter].forEach((el) => { | ||
| el.addEventListener(eventName, () => dashboard && renderMatches()); | ||
| }); | ||
| }); | ||
| els.refreshButton.addEventListener('click', loadDashboard); | ||
| loadDashboard().catch((error) => { | ||
| els.healthBanner.className = 'health-banner broken'; | ||
| els.healthBanner.textContent = `APP BROKEN: ${error.message}`; | ||
| }); |
There was a problem hiding this comment.
Handle failed refreshes explicitly instead of leaving an unhandled rejection.
loadDashboard() never checks response.ok, and the refresh button calls it without a .catch(...). A 500/404 or bad payload during manual refresh leaves the banner on Loading... and pushes the failure into an unhandled promise rejection path.
Suggested fix
+function showLoadError(error) {
+ els.healthBanner.className = 'health-banner broken';
+ els.healthBanner.textContent = `APP BROKEN: ${error.message}`;
+}
+
async function loadDashboard() {
els.healthBanner.textContent = 'Loading...';
const response = await fetch('/api/dashboard', { cache: 'no-store' });
+ if (!response.ok) {
+ throw new Error(`Dashboard request failed (${response.status})`);
+ }
dashboard = await response.json();
render();
}
@@
-els.refreshButton.addEventListener('click', loadDashboard);
-loadDashboard().catch((error) => {
- els.healthBanner.className = 'health-banner broken';
- els.healthBanner.textContent = `APP BROKEN: ${error.message}`;
-});
+els.refreshButton.addEventListener('click', () => loadDashboard().catch(showLoadError));
+loadDashboard().catch(showLoadError);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function loadDashboard() { | |
| els.healthBanner.textContent = 'Loading...'; | |
| const response = await fetch('/api/dashboard', { cache: 'no-store' }); | |
| dashboard = await response.json(); | |
| render(); | |
| } | |
| ['change', 'input'].forEach((eventName) => { | |
| [els.viewFilter, els.teamFilter, els.stageFilter, els.venueFilter, els.searchFilter].forEach((el) => { | |
| el.addEventListener(eventName, () => dashboard && renderMatches()); | |
| }); | |
| }); | |
| els.refreshButton.addEventListener('click', loadDashboard); | |
| loadDashboard().catch((error) => { | |
| els.healthBanner.className = 'health-banner broken'; | |
| els.healthBanner.textContent = `APP BROKEN: ${error.message}`; | |
| }); | |
| function showLoadError(error) { | |
| els.healthBanner.className = 'health-banner broken'; | |
| els.healthBanner.textContent = `APP BROKEN: ${error.message}`; | |
| } | |
| async function loadDashboard() { | |
| els.healthBanner.textContent = 'Loading...'; | |
| const response = await fetch('/api/dashboard', { cache: 'no-store' }); | |
| if (!response.ok) { | |
| throw new Error(`Dashboard request failed (${response.status})`); | |
| } | |
| dashboard = await response.json(); | |
| render(); | |
| } | |
| ['change', 'input'].forEach((eventName) => { | |
| [els.viewFilter, els.teamFilter, els.stageFilter, els.venueFilter, els.searchFilter].forEach((el) => { | |
| el.addEventListener(eventName, () => dashboard && renderMatches()); | |
| }); | |
| }); | |
| els.refreshButton.addEventListener('click', () => loadDashboard().catch(showLoadError)); | |
| loadDashboard().catch(showLoadError); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/web/app.js` around lines 164 - 180, The
loadDashboard function does not check response.ok or handle JSON parsing errors
and the refresh button calls loadDashboard without handling rejections; update
loadDashboard to check response.ok and throw a descriptive error when the fetch
fails, wrap the response.json() in try/catch to handle bad payloads, and ensure
on any error you set els.healthBanner.className to 'health-banner broken' and
set els.healthBanner.textContent to a clear message; also change the
els.refreshButton click handler to call loadDashboard().catch(...) (or otherwise
handle the returned promise) so manual refresh failures are caught and invoke
the same banner update path; reference loadDashboard, els.refreshButton,
els.healthBanner, render, and renderMatches when applying the fix.
| --blue: #4dabf7; | ||
| } | ||
| * { box-sizing: border-box; } | ||
| body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, Segoe UI, Arial, sans-serif; } |
There was a problem hiding this comment.
Quote "Segoe UI" to satisfy the configured Stylelint rule.
This declaration currently violates font-family-name-quotes, so the stylesheet will keep failing lint until the family name is quoted.
Suggested fix
-body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, Segoe UI, Arial, sans-serif; }
+body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, "Segoe UI", Arial, sans-serif; }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, Segoe UI, Arial, sans-serif; } | |
| body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, "Segoe UI", Arial, sans-serif; } |
🧰 Tools
🪛 Stylelint (17.12.0)
[error] 14-14: Expected quotes around "Segoe UI" (font-family-name-quotes)
(font-family-name-quotes)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sports/FootballVMProphet/web/styles.css` at line 14, The font-family
declaration on the body rule uses an unquoted multi-word family name; update the
font-family in the body selector (the body { ... font-family: ... } rule) to
quote "Segoe UI" (use double quotes) so it satisfies the font-family-name-quotes
Stylelint rule, e.g. change Segoe UI to "Segoe UI" while keeping the rest of the
stack unchanged.
Source: Linters/SAST tools
Summary
Sports/FootballVMProphet, a free local FIFA World Cup 2026 dashboard.Free data model
FREE_LIVE_ENDPOINTcan be added later for free live score data.Validation
npm testpassed with Node v20.20.2./api/healthreturns data-health JSON./api/dashboardreturns 5 seed matches and 3 odds summary rows./serves the dashboard HTML.Limitations
Summary by CodeRabbit
Release Notes
New Features
Documentation