feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { FootballApiMatch, FootballApiResponse } from '../types';
import { logger } from './logger';
const BASE_URL = process.env.FOOTBALL_API_BASE_URL ?? 'https://api.football-data.org/v4';
const API_KEY = process.env.FOOTBALL_API_KEY ?? '';
// WM 2026 Competition Code bei football-data.org
const WC_2026_CODE = 'WC';
/**
* Rate limiting: Free tier = 10 Requests/Minute
* Wir tracken den letzten Request-Zeitpunkt
*/
const requestQueue: Array<() => void> = [];
let lastRequestTime = 0;
const MIN_REQUEST_INTERVAL = 6100; // ~10 req/min with safety buffer
const throttledFetch = async (url: string): Promise<Response> => {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
const waitTime = MIN_REQUEST_INTERVAL - timeSinceLastRequest;
logger.debug(`Rate limiting: waiting ${waitTime}ms`);
await new Promise(r => setTimeout(r, waitTime));
}
lastRequestTime = Date.now();
try {
const response = await fetch(url, {
headers: {
'X-Auth-Token': API_KEY,
'Accept': 'application/json',
},
});
return response;
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('football-data.org fetch failed', { url, error: msg });
throw new Error(`Netzwerkfehler beim Abruf von football-data.org: ${msg}`);
}
};
/**
* Alle Spiele der WM 2026 von football-data.org abrufen
*/
export const fetchAllMatches = async (): Promise<FootballApiMatch[]> => {
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches`;
logger.info('Fetching WC 2026 matches from football-data.org');
const response = await throttledFetch(url);
if (response.status === 429) {
throw new Error('football-data.org rate limit exceeded');
}
if (!response.ok) {
const body = await response.text();
throw new Error(
`football-data.org API error: ${response.status} ${response.statusText} ${body}`
);
}
const data = (await response.json()) as FootballApiResponse;
logger.info(`Fetched ${data.matches.length} matches from football-data.org`);
return data.matches;
};
/**
* Nur FINISHED Spiele abrufen (für Punkte-Berechnung)
*/
export const fetchFinishedMatches = async (): Promise<FootballApiMatch[]> => {
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches?status=FINISHED`;
const response = await throttledFetch(url);
if (!response.ok) {
throw new Error(`football-data.org API error: ${response.status}`);
}
const data = (await response.json()) as FootballApiResponse;
return data.matches;
};
/**
* Ein einzelnes Spiel abrufen (für Live-Updates)
*/
export const fetchMatch = async (matchId: number): Promise<FootballApiMatch> => {
const url = `${BASE_URL}/matches/${matchId}`;
const response = await throttledFetch(url);
if (!response.ok) {
throw new Error(`football-data.org API error: ${response.status}`);
}
const data = (await response.json()) as { match: FootballApiMatch };
return data.match;
};
+21
View File
@@ -0,0 +1,21 @@
import winston from 'winston';
export const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
process.env.NODE_ENV === 'production'
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length
? '\n' + JSON.stringify(meta, null, 2)
: '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
)
),
transports: [new winston.transports.Console()],
});
+87
View File
@@ -0,0 +1,87 @@
import { PointsCalculation, POINTS } from '../types';
interface Score {
home: number;
away: number;
}
/**
* Berechnet die Punkte für einen Tipp.
*
* Punkteregeln:
* - Exaktes Ergebnis: 3 Punkte
* - Richtige Tendenz: 1 Punkt (Sieg/Unentschieden/Niederlage korrekt)
* - Falsche Tendenz: 0 Punkte
*
* @param tip - Der abgegebene Tipp
* @param actual - Das tatsächliche Ergebnis
*/
export const calculatePoints = (
tip: Score,
actual: Score
): PointsCalculation => {
// Exaktes Ergebnis
if (tip.home === actual.home && tip.away === actual.away) {
return { result: 'exact', points: POINTS.EXACT };
}
// Tendenz prüfen (Sieg Heim / Unentschieden / Sieg Auswärts)
const tipTendency = getTendency(tip.home, tip.away);
const actualTendency = getTendency(actual.home, actual.away);
if (tipTendency === actualTendency) {
return { result: 'tendency', points: POINTS.TENDENCY };
}
return { result: 'wrong', points: POINTS.WRONG };
};
type Tendency = 'home' | 'draw' | 'away';
const getTendency = (home: number, away: number): Tendency => {
if (home > away) return 'home';
if (home < away) return 'away';
return 'draw';
};
/**
* Berechnet Punkte für die gesamte Tipp-History eines Users.
* Nützlich für Statistiken.
*/
export const calculateUserStats = (
tips: Array<{
tipHome: number;
tipAway: number;
actualHome: number | null;
actualAway: number | null;
points: number | null;
}>
) => {
const evaluated = tips.filter(
(t) => t.actualHome !== null && t.actualAway !== null
);
const exactCount = evaluated.filter((t) => t.points === POINTS.EXACT).length;
const tendencyCount = evaluated.filter(
(t) => t.points === POINTS.TENDENCY
).length;
const wrongCount = evaluated.filter((t) => t.points === POINTS.WRONG).length;
const totalPoints = evaluated.reduce((sum, t) => sum + (t.points ?? 0), 0);
const accuracy =
evaluated.length > 0
? Math.round(
((exactCount + tendencyCount) / evaluated.length) * 100
)
: 0;
return {
totalPoints,
exactCount,
tendencyCount,
wrongCount,
accuracy,
evaluatedCount: evaluated.length,
pendingCount: tips.length - evaluated.length,
};
};
+176
View File
@@ -0,0 +1,176 @@
import { query, withTransaction } from '../db/client';
import { fetchAllMatches, fetchFinishedMatches } from './footballApi';
import { calculatePoints } from './pointsService';
import { FootballApiMatch } from '../types';
import { logger } from './logger';
/**
* Synchronisiert alle WM-Spiele von football-data.org in die Datenbank.
* Legt neue Spiele an und aktualisiert bestehende (Status + Score).
*
* Sollte täglich per Cron laufen, oder manuell getriggert werden.
*/
export const syncMatches = async (): Promise<{
total: number;
created: number;
updated: number;
}> => {
logger.info('Starting match sync...');
const startTime = Date.now();
let created = 0;
let updated = 0;
try {
const apiMatches = await fetchAllMatches();
await withTransaction(async (client) => {
for (const match of apiMatches) {
const existing = await client.query<{ id: number; status: string }>(
'SELECT id, status FROM matches WHERE external_id = $1',
[match.id]
);
if (existing.rows.length === 0) {
// Neues Spiel anlegen
await client.query(
`INSERT INTO matches (
external_id, utc_date, status, stage, group_name,
home_team_id, away_team_id,
home_team_name, away_team_name,
home_team_short, away_team_short,
home_team_crest, away_team_crest,
score_home, score_away
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
[
match.id,
match.utcDate,
match.status,
match.stage,
match.group,
match.homeTeam?.id ?? null,
match.awayTeam?.id ?? null,
match.homeTeam?.name ?? 'TBD',
match.awayTeam?.name ?? 'TBD',
match.homeTeam?.shortName || match.homeTeam?.tla || 'TBD',
match.awayTeam?.shortName || match.awayTeam?.tla || 'TBD',
match.homeTeam?.crest ?? null,
match.awayTeam?.crest ?? null,
match.score.fullTime.home,
match.score.fullTime.away,
]
);
created++;
} else {
// Bestehendes Spiel aktualisieren
await client.query(
`UPDATE matches SET
status = $1,
score_home = $2,
score_away = $3,
utc_date = $4
WHERE external_id = $5`,
[
match.status,
match.score.fullTime.home,
match.score.fullTime.away,
match.utcDate,
match.id,
]
);
updated++;
}
}
});
// Sync-Log schreiben
await query(
`INSERT INTO sync_log (matches_total, matches_new, matches_upd, status)
VALUES ($1, $2, $3, 'success')`,
[apiMatches.length, created, updated]
);
const duration = Date.now() - startTime;
logger.info(`Match sync completed in ${duration}ms`, {
total: apiMatches.length,
created,
updated,
});
return { total: apiMatches.length, created, updated };
} catch (error) {
// Fehler loggen
await query(
`INSERT INTO sync_log (matches_total, matches_new, matches_upd, status, error_msg)
VALUES (0, 0, 0, 'error', $1)`,
[(error as Error).message]
).catch(() => {}); // Fehler beim Fehler-Logging ignorieren
logger.error('Match sync failed', { error });
throw error;
}
};
/**
* Wertet Tipps für alle abgeschlossenen Spiele aus.
* Berechnet Punkte und aktualisiert die tips-Tabelle.
* Refreshed danach die Leaderboard Materialized View.
*
* Sollte nach jedem abgeschlossenen Spiel laufen.
*/
export const evaluateTips = async (): Promise<{
matchesEvaluated: number;
tipsUpdated: number;
}> => {
logger.info('Starting tip evaluation...');
// Alle abgeschlossenen Spiele mit noch nicht ausgewerteten Tipps
const pendingMatches = await query<{
match_id: number;
score_home: number;
score_away: number;
}>(
`SELECT DISTINCT m.id AS match_id, m.score_home, m.score_away
FROM matches m
JOIN tips t ON t.match_id = m.id
WHERE m.status = 'FINISHED'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND t.points IS NULL`
);
let tipsUpdated = 0;
for (const match of pendingMatches) {
// Alle Tipps für dieses Spiel
const tips = await query<{
id: number;
tip_home: number;
tip_away: number;
}>(
'SELECT id, tip_home, tip_away FROM tips WHERE match_id = $1 AND points IS NULL',
[match.match_id]
);
for (const tip of tips) {
const { points } = calculatePoints(
{ home: tip.tip_home, away: tip.tip_away },
{ home: match.score_home, away: match.score_away }
);
await query('UPDATE tips SET points = $1 WHERE id = $2', [points, tip.id]);
tipsUpdated++;
}
}
// Leaderboard Materialized View aktualisieren
if (tipsUpdated > 0) {
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
logger.info(`Leaderboard refreshed after evaluating ${tipsUpdated} tips`);
}
return {
matchesEvaluated: pendingMatches.length,
tipsUpdated,
};
};