feat: football api

This commit is contained in:
Krzysztof kuhy Rudnicki 2025-08-24 13:56:30 +02:00
parent 4125436fec
commit 1850b2fa27
14 changed files with 4953 additions and 0 deletions

View File

@ -0,0 +1,2 @@
FOOTBALL_DATA_API_KEY=your_api_token_here
PORT=8787

4
TS/champions_leauge_scores/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
.env
dist
.DS_Store

View File

@ -0,0 +1,34 @@
# Champions League Live Scores (React + TS)
This app displays live and today's UEFA Champions League results. It uses:
- React + TypeScript (Vite) for the frontend
- A tiny Express proxy server that calls football-data.org to fetch match data
## Setup
1) Create a `.env` file in `TS/champions_leauge_scores/`:
```
FOOTBALL_DATA_API_KEY=your_api_token_here
PORT=8787
```
Sign up at https://www.football-data.org/ to get a free API token. Free tier has rate limits.
2) Install dependencies and run both servers:
```
npm install
npm run dev
```
- Frontend: http://localhost:5173
- API Proxy: http://localhost:8787
## Notes
- Live endpoint: `GET /api/live`
- Today endpoint: `GET /api/matches` (uses today's date by default)
- Edit polling intervals in `src/App.tsx` if needed.
## License
MIT

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Champions League Live Scores</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "champions-league-scores",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"npm:server:dev\"",
"build": "vite build",
"preview": "vite preview",
"server:dev": "tsx watch server/src/server.ts"
},
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"cors": "^2.8.5",
"express": "^4.19.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
"typescript": "^5.4.5",
"vite": "^5.3.3"
}
}

View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -Eeuo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
echo "[run] Working dir: $HERE"
if ! command -v node >/dev/null 2>&1; then
echo "[run] Node.js is required. Please install Node.js >= 18." >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "[run] npm is required. Please install npm." >&2
exit 1
fi
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
echo "[run] No .env found. Creating one from .env.example"
cp -n .env.example .env || true
else
echo "[run] .env file missing and .env.example not found."
fi
fi
free_port() {
local port="$1"
local attempts=0
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids=$(lsof -ti tcp:"$port" || true)
fi
if [[ -z "$pids" ]] && command -v fuser >/dev/null 2>&1; then
# fuser prints PIDs and returns 0 if in use
if fuser -n tcp "$port" >/dev/null 2>&1; then
pids=$(fuser -n tcp "$port" 2>/dev/null | tr ' ' '\n' | tr -d '\n')
fi
fi
if [[ -n "$pids" ]]; then
echo "[run] Port $port in use by: $pids — terminating..."
kill $pids || true
# wait until freed (up to ~5s), escalate if needed
while [[ $attempts -lt 25 ]]; do
sleep 0.2
if command -v lsof >/dev/null 2>&1; then
lsof -ti tcp:"$port" >/dev/null || break
else
break
fi
attempts=$((attempts+1))
done
if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"$port" >/dev/null; then
echo "[run] Port $port still busy — forcing kill..."
kill -9 $pids || true
sleep 0.2
fi
fi
}
echo "[run] Ensuring ports 5173 and 8787 are free..."
free_port 5173 || true
free_port 8787 || true
echo "[run] Installing dependencies (if needed)..."
if [[ -f package-lock.json ]]; then
npm ci || npm install
else
npm install
fi
echo "[run] Starting dev servers (frontend + API proxy)..."
# Ensure child processes are terminated on exit (Ctrl+C or script end)
cleanup() {
echo "[run] Shutting down dev servers..."
# Kill entire process group of this script
pkill -P $$ || true
# Also free ports in case processes detached
free_port 5173 || true
free_port 8787 || true
}
trap cleanup INT TERM EXIT
# Run in foreground so logs are visible; trap will handle cleanup
npm run dev

View File

@ -0,0 +1,166 @@
import express, { Request, Response } from 'express';
import axios from 'axios';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const PORT = Number(process.env.PORT || 8787);
const API_BASE = 'https://api.football-data.org/v4';
const API_TOKEN = process.env.FOOTBALL_DATA_API_KEY;
if (!API_TOKEN) {
// eslint-disable-next-line no-console
console.warn('[server] FOOTBALL_DATA_API_KEY is not set. Live data will not work until you set it.');
}
const app = express();
app.use(cors());
app.disable('etag');
app.use((_req, res, next) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
});
// Simple request/response logging middleware
app.use((req, res, next) => {
const start = process.hrtime.bigint();
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
// Attach id so downstream handlers could use it if needed
(res as any).locals = { ...(res as any).locals, requestId: id };
// eslint-disable-next-line no-console
console.log(`[#${id}] -> ${req.method} ${req.originalUrl} ` +
(Object.keys(req.query || {}).length ? `query=${JSON.stringify(req.query)}` : ''));
res.on('finish', () => {
const durMs = Number(process.hrtime.bigint() - start) / 1_000_000;
// eslint-disable-next-line no-console
console.log(`[#${id}] <- ${req.method} ${req.originalUrl} ${res.statusCode} ${durMs.toFixed(1)}ms`);
});
next();
});
// Axios interceptors to log outgoing requests and incoming responses
axios.interceptors.request.use((config) => {
(config as any).metadata = { start: Date.now() };
// eslint-disable-next-line no-console
console.log(`[axios ->] ${String(config.method || 'GET').toUpperCase()} ${config.url}`);
return config;
}, (error) => {
// eslint-disable-next-line no-console
console.warn('[axios req error]', error?.message || error);
return Promise.reject(error);
});
axios.interceptors.response.use((response) => {
const started = (response.config as any).metadata?.start || Date.now();
const dur = Date.now() - started;
let size = 0;
try {
const d = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
size = d?.length || 0;
} catch (_e) { /* ignore */ }
// eslint-disable-next-line no-console
console.log(`[axios <-] ${response.status} ${String(response.config.method || 'GET').toUpperCase()} ${response.config.url} ${dur}ms ~${size}B`);
return response;
}, (error) => {
const cfg = error?.config || {};
const started = (cfg as any).metadata?.start || Date.now();
const dur = Date.now() - started;
const status = error?.response?.status;
// eslint-disable-next-line no-console
console.warn(`[axios ! ] ${status ?? 'ERR'} ${String(cfg.method || 'GET').toUpperCase()} ${cfg.url} ${dur}ms`, error?.response?.data || error?.message);
return Promise.reject(error);
});
app.get('/health', (_req: Request, res: Response) => res.json({ ok: true }));
function buildHeaders() {
return {
'X-Auth-Token': API_TOKEN || '',
} as Record<string, string>;
}
function normalizeMatch(m: any) {
return {
id: m.id,
utcDate: m.utcDate,
status: m.status,
stage: m.stage,
group: m.group,
matchday: m.matchday,
homeTeam: m.homeTeam?.name,
awayTeam: m.awayTeam?.name,
score: m.score,
competition: m.competition?.name || 'UEFA Champions League',
venue: m.venue,
referees: m.referees?.map((r: any) => r.name).filter(Boolean) || [],
};
}
app.get('/api/live', async (_req: Request, res: Response) => {
try {
if (!API_TOKEN) {
const demo = [{
id: 1,
utcDate: new Date().toISOString(),
status: 'LIVE',
stage: 'GROUP_STAGE',
group: 'Group A',
matchday: 1,
homeTeam: { name: 'Demo FC' },
awayTeam: { name: 'Sample United' },
score: { fullTime: { home: 1, away: 0 }, halfTime: { home: 1, away: 0 } },
competition: { name: 'UEFA Champions League' },
}];
return res.json({ count: demo.length, matches: demo.map(normalizeMatch), fetchedAt: new Date().toISOString(), demo: true });
}
const url = `${API_BASE}/competitions/CL/matches`;
const { data } = await axios.get(url, { headers: buildHeaders(), params: { status: 'LIVE' } });
const matches = (data.matches || []).map(normalizeMatch);
res.json({ count: matches.length, matches, fetchedAt: new Date().toISOString() });
} catch (err: any) {
const status = err?.response?.status || 500;
res.status(status).json({ error: 'Failed to fetch live matches', details: err?.response?.data || err?.message });
}
});
app.get('/api/matches', async (req: Request, res: Response) => {
try {
const date = (req.query.date as string) || new Date().toISOString().slice(0, 10);
if (!API_TOKEN) {
const demo = [{
id: 2,
utcDate: new Date().toISOString(),
status: 'TIMED',
stage: 'GROUP_STAGE',
group: 'Group B',
matchday: 1,
homeTeam: { name: 'Placeholder City' },
awayTeam: { name: 'Mock Rovers' },
score: { fullTime: { home: null, away: null }, halfTime: { home: null, away: null } },
competition: { name: 'UEFA Champions League' },
}];
return res.json({ count: demo.length, matches: demo.map(normalizeMatch), fetchedAt: new Date().toISOString(), date, demo: true });
}
const url = `${API_BASE}/competitions/CL/matches`;
const { data } = await axios.get(url, {
headers: buildHeaders(),
params: { dateFrom: date, dateTo: date },
});
const matches = (data.matches || []).map(normalizeMatch);
res.json({ count: matches.length, matches, fetchedAt: new Date().toISOString() });
} catch (err: any) {
const status = err?.response?.status || 500;
res.status(status).json({ error: 'Failed to fetch matches', details: err?.response?.data || err?.message });
}
});
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`[server] Listening on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}

View File

@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
type Score = {
fullTime?: { home?: number | null; away?: number | null };
halfTime?: { home?: number | null; away?: number | null };
winner?: string | null;
};
type Match = {
id: number;
utcDate: string;
status: string;
stage?: string;
group?: string;
matchday?: number;
homeTeam: string;
awayTeam: string;
score: Score;
competition?: string;
venue?: string;
referees?: string[];
};
type ApiResponse = {
count: number;
matches: Match[];
fetchedAt: string;
};
function useFetchOnce<T>(fn: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let mounted = true;
(async () => {
try {
const result = await fn();
if (mounted) {
setData(result);
setError(null);
}
} catch (e: any) {
if (mounted) setError(e?.message || 'Failed to fetch');
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, [fn]);
return { data, error, loading } as const;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { cache: 'no-store', ...init });
if (!res.ok) {
const text = await res.text();
let body: any = null;
try { body = text ? JSON.parse(text) : null; } catch { /* noop */ }
const err: any = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.body = body;
// Try to derive wait seconds for 429 from body.details.message like: "You reached your request limit. Wait 56 seconds."
if (res.status === 429) {
const msg: string | undefined = body?.details?.message || body?.message || body?.error;
const m = msg ? msg.match(/(\d+)\s*seconds?/) : null;
if (m) err.waitSec = Number(m[1]);
}
throw err;
}
return res.json();
}
function MatchCard({ m }: { m: Match }) {
const kickoff = useMemo(() => new Date(m.utcDate), [m.utcDate]);
const time = kickoff.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const date = kickoff.toLocaleDateString();
const ftHome = m.score.fullTime?.home ?? '-';
const ftAway = m.score.fullTime?.away ?? '-';
const statusNice = m.status.replace('_', ' ');
return (
<div className="card">
<div className="row teams">
<span className="home">{m.homeTeam}</span>
<span className="score">{ftHome} : {ftAway}</span>
<span className="away">{m.awayTeam}</span>
</div>
<div className="row meta">
<span>{statusNice}</span>
<span>{date} {time}</span>
{m.group && <span>{m.group}</span>}
{m.stage && <span>{m.stage}</span>}
</div>
</div>
);
}
function useBackoffUntilSuccess<T>(fn: () => Promise<T>, opts?: { baseDelaySec?: number; maxDelaySec?: number; factor?: number }) {
const base = Math.max(1, opts?.baseDelaySec ?? 30);
const max = Math.max(base, opts?.maxDelaySec ?? 300);
const factor = Math.max(1.1, opts?.factor ?? 2);
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [retryInSec, setRetryInSec] = useState<number | null>(null);
const delayRef = useRef<number>(base);
const tRetryRef = useRef<number | null>(null);
const tTickRef = useRef<number | null>(null);
const inFlightRef = useRef<boolean>(false);
useEffect(() => {
let mounted = true;
const clearTimers = () => {
if (tRetryRef.current) { window.clearTimeout(tRetryRef.current); tRetryRef.current = null; }
if (tTickRef.current) { window.clearInterval(tTickRef.current); tTickRef.current = null; }
};
const scheduleRetry = (sec: number) => {
clearTimers();
const clamped = Math.min(Math.max(1, Math.floor(sec)), max);
setRetryInSec(clamped);
// countdown ticker
tTickRef.current = window.setInterval(() => {
setRetryInSec(v => (v && v > 0 ? v - 1 : 0));
}, 1000);
tRetryRef.current = window.setTimeout(() => {
if (!mounted) return;
clearTimers();
run();
}, clamped * 1000);
};
const run = async () => {
if (inFlightRef.current) return; // avoid overlapping calls
try {
inFlightRef.current = true;
setLoading(true);
const result = await fn();
if (!mounted) return;
clearTimers();
setData(result);
setError(null);
} catch (e: any) {
if (!mounted) return;
// 429: backoff and retry
if (e?.status === 429) {
const suggested = Number(e?.waitSec) || delayRef.current || base;
const next = Math.min(max, Math.max(base, suggested));
delayRef.current = Math.min(max, Math.ceil(next * factor));
setError(`Rate limited. Retrying in ${next}s...`);
scheduleRetry(next);
return;
}
setError(e?.message || 'Failed to fetch');
} finally {
inFlightRef.current = false;
if (mounted) setLoading(false);
}
};
run();
return () => { mounted = false; clearTimers(); };
}, [fn, base, max, factor]);
return { data, error, loading, retryInSec } as const;
}
export default function App() {
const fetchLive = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/live', { headers: { 'cache-control': 'no-cache' } }), []);
const fetchToday = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/matches', { headers: { 'cache-control': 'no-cache' } }), []);
const live = useBackoffUntilSuccess<ApiResponse>(fetchLive);
const today = useBackoffUntilSuccess<ApiResponse>(fetchToday);
return (
<div className="app">
<h1>UEFA Champions League Live Scores</h1>
<section>
<h2>Live right now</h2>
{live.loading && <p>Loading</p>}
{live.error && <p className="error">{live.error}{typeof live.retryInSec === 'number' ? ` (${live.retryInSec}s)` : ''}</p>}
{!live.loading && !live.error && live.data?.matches?.length === 0 && <p>No live matches.</p>}
<div className="grid">
{live.data?.matches?.map(m => <MatchCard key={m.id} m={m} />)}
</div>
</section>
<section>
<h2>Today</h2>
{today.loading && <p>Loading</p>}
{today.error && <p className="error">{today.error}{typeof today.retryInSec === 'number' ? ` (${today.retryInSec}s)` : ''}</p>}
<div className="grid">
{today.data?.matches?.map(m => <MatchCard key={m.id} m={m} />)}
</div>
</section>
<style>{`
:root{ color-scheme: light dark; }
body, html, #root { margin: 0; height: 100%; }
.app { max-width: 900px; margin: 0 auto; padding: 1rem; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
h1 { font-size: 1.6rem; margin: 0 0 1rem; }
h2 { font-size: 1.2rem; margin: 1.2rem 0 .6rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: .75rem; }
.card { border: 1px solid #4444; border-radius: 10px; padding: .75rem; background: #1112; backdrop-filter: blur(2px); }
.row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.teams { font-weight: 600; }
.score { font-variant-numeric: tabular-nums; font-size: 1.2rem; }
.meta { opacity: .8; font-size: .85rem; margin-top: .5rem; }
.error { color: #d33; }
`}</style>
</div>
);
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const el = document.getElementById('root');
if (el) {
createRoot(el).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true
},
preview: {
port: 5173
}
});