mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 15:23:06 +02:00
feat: football api
This commit is contained in:
parent
4125436fec
commit
1850b2fa27
2
TS/champions_leauge_scores/.env.example
Normal file
2
TS/champions_leauge_scores/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
FOOTBALL_DATA_API_KEY=your_api_token_here
|
||||||
|
PORT=8787
|
||||||
4
TS/champions_leauge_scores/.gitignore
vendored
Normal file
4
TS/champions_leauge_scores/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
34
TS/champions_leauge_scores/README.md
Normal file
34
TS/champions_leauge_scores/README.md
Normal 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
|
||||||
12
TS/champions_leauge_scores/index.html
Normal file
12
TS/champions_leauge_scores/index.html
Normal 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>
|
||||||
4346
TS/champions_leauge_scores/package-lock.json
generated
Normal file
4346
TS/champions_leauge_scores/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
TS/champions_leauge_scores/package.json
Normal file
33
TS/champions_leauge_scores/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
86
TS/champions_leauge_scores/run.sh
Executable file
86
TS/champions_leauge_scores/run.sh
Executable 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
|
||||||
166
TS/champions_leauge_scores/server/src/server.ts
Normal file
166
TS/champions_leauge_scores/server/src/server.ts
Normal 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}`);
|
||||||
|
});
|
||||||
14
TS/champions_leauge_scores/server/tsconfig.json
Normal file
14
TS/champions_leauge_scores/server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
212
TS/champions_leauge_scores/src/App.tsx
Normal file
212
TS/champions_leauge_scores/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
TS/champions_leauge_scores/src/main.tsx
Normal file
12
TS/champions_leauge_scores/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
TS/champions_leauge_scores/src/vite-env.d.ts
vendored
Normal file
1
TS/champions_leauge_scores/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
TS/champions_leauge_scores/tsconfig.json
Normal file
18
TS/champions_leauge_scores/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
13
TS/champions_leauge_scores/vite.config.ts
Normal file
13
TS/champions_leauge_scores/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user