Files
worldcup/lib/graphql/resolvers/index.ts
T
valknar 9ce2a4e27c fix: use full player names from title attr, preserve UTC offset in match times
Wikipedia abbreviates goal scorer display text (e.g. "Müller") but the
<a title="Thomas Müller"> attribute always has the full name. Switch
parseGoals() to prefer title attr and strip disambiguation suffixes like
"(soccer, born 1993)". This ensures Gerd Müller and Thomas Müller get
separate player pages.

Also preserve the UTC offset from Wikipedia's ftime (e.g. "12:00 UTC-4")
so that isLive() can accurately compute UTC kickoff time instead of
treating local time as UTC. upcomingMatches sorts by SPLIT_PART on the
HH:MM part to ignore the timezone suffix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:14:53 +02:00

490 lines
22 KiB
TypeScript

import { db } from '@/lib/db'
import { tournaments, teams, matches, goals, groupStandings, stadiums, squads } from '@/lib/db/schema'
import { slugify, getIso } from '@/lib/iso-codes'
import { eq, and, desc, asc, sql, ilike, or, isNotNull, lt, lte, gt, gte } from 'drizzle-orm'
function teamWithSlug(t: typeof teams.$inferSelect) {
return { ...t, slug: slugify(t.name), iso2: t.iso2 ?? getIso(t.name) }
}
async function getTeamById(id: number) {
const rows = await db.select().from(teams).where(eq(teams.id, id)).limit(1)
return rows[0] ? teamWithSlug(rows[0]) : null
}
function isLive(dateStr: string | null, timeStr: string | null): boolean {
if (!dateStr) return false
const now = new Date()
if (now.toISOString().slice(0, 10) !== dateStr) return false
if (!timeStr) return true
// Parse "15:00 UTC-5" → convert to UTC kickoff time
const m = timeStr.match(/^(\d{1,2}):(\d{2})(?:\s*UTC([+-]\d+(?:\.\d+)?))?/)
if (!m) return true
const localH = parseInt(m[1])
const localMin = parseInt(m[2])
const tzOffset = m[3] ? parseFloat(m[3]) : 0 // e.g. -5 for UTC-5
// UTC = local time - tz offset (15:00 UTC-5 → 15:00 - (-5h) = 20:00 UTC)
const kickoff = new Date(`${dateStr}T00:00:00Z`)
kickoff.setUTCMinutes(kickoff.getUTCMinutes() + localH * 60 + localMin - tzOffset * 60)
const diffMin = (now.getTime() - kickoff.getTime()) / 60000
return diffMin >= -5 && diffMin <= 125
}
async function hydrateMatch(row: typeof matches.$inferSelect) {
const [t1, t2] = await Promise.all([getTeamById(row.team1Id), getTeamById(row.team2Id)])
const matchGoals = await db.select().from(goals).where(eq(goals.matchId, row.id)).orderBy(asc(goals.minute))
const goalsHydrated = await Promise.all(matchGoals.map(async g => ({
...g,
team: (await getTeamById(g.teamId)) ?? { id: g.teamId, name: '', slug: '', iso2: null, fifaCode: null, continent: null, confederation: null },
isPenalty: g.isPenalty ?? false,
isOwnGoal: g.isOwnGoal ?? false,
minuteOffset: g.minuteOffset ?? 0,
})))
const ft = row.scoreFtHome !== null ? [row.scoreFtHome, row.scoreFtAway!] : null
const ht = row.scoreHtHome !== null ? [row.scoreHtHome, row.scoreHtAway!] : null
const et = row.scoreEtHome !== null ? [row.scoreEtHome, row.scoreEtAway!] : null
const p = row.scorePHome !== null ? [row.scorePHome, row.scorePAway!] : null
const margin = ft ? Math.abs(ft[0] - ft[1]) : null
const totalGoalsCount = ft ? ft[0] + ft[1] : null
return {
...row,
year: row.tournamentYear,
group: row.groupName,
time: row.timeLocal,
stadium: null,
team1: t1!, team2: t2!,
scoreFt: ft, scoreHt: ht, scoreEt: et, scoreP: p,
goals: goalsHydrated,
isLive: isLive(row.date, row.timeLocal),
isQualiPlayoff: row.isQualiPlayoff ?? false,
margin, totalGoals: totalGoalsCount,
}
}
function isMissingTable(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e)
return msg.includes('relation') && msg.includes('does not exist')
}
export const resolvers = {
Query: {
async tournaments() {
try {
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async tournament(_: unknown, { year }: { year: number }) {
try {
const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
if (!rows[0]) return null
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
} catch (e) { if (isMissingTable(e)) return null; throw e }
},
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean; teamId?: number }) {
try {
const conditions = []
if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
if (args.group) conditions.push(eq(matches.groupName, args.group))
if (args.round) conditions.push(eq(matches.round, args.round))
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
if (args.teamId) conditions.push(or(eq(matches.team1Id, args.teamId), eq(matches.team2Id, args.teamId))!)
const rows = await db.select().from(matches)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(asc(matches.date), asc(matches.id))
return Promise.all(rows.map(hydrateMatch))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async match(_: unknown, { id }: { id: number }) {
try {
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
return rows[0] ? hydrateMatch(rows[0]) : null
} catch (e) { if (isMissingTable(e)) return null; throw e }
},
async liveMatches() {
try {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false)))
.orderBy(asc(matches.id))
const hydrated = await Promise.all(rows.map(hydrateMatch))
return hydrated.filter(m => m.isLive)
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
try {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(
lte(matches.date, today),
isNotNull(matches.scoreFtHome),
eq(matches.isQualiPlayoff, false),
))
.orderBy(desc(matches.date), desc(matches.id))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
try {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(
gte(matches.date, today),
sql`${matches.scoreFtHome} IS NULL`,
eq(matches.isQualiPlayoff, false),
))
.orderBy(asc(matches.date), sql`SPLIT_PART(${matches.timeLocal}, ' ', 1) ASC NULLS LAST`, asc(matches.id))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async teams() {
try {
const rows = await db.select().from(teams).orderBy(asc(teams.name))
return rows.map(teamWithSlug)
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async team(_: unknown, { slug }: { slug: string }) {
try {
const rows = await db.select().from(teams)
const found = rows.find(r => slugify(r.name) === slug)
return found ? teamWithSlug(found) : null
} catch (e) { if (isMissingTable(e)) return null; throw e }
},
async topScorers(_: unknown, { year, limit = 20, teamId }: { year?: number; limit?: number; teamId?: number }) {
try {
const conditions = sql`
${year ? sql`AND m.tournament_year = ${year}` : sql``}
${teamId ? sql`AND g.team_id = ${teamId}` : sql``}
AND m.is_quali_playoff = false
`
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE true ${conditions}
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC
LIMIT ${limit}
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name,
goals: r.goals,
penalties: r.penalties,
ownGoals: r.own_goals,
tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
})))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async player(_: unknown, { name }: { name: string }) {
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE LOWER(g.player_name) LIKE LOWER(${`%${name}%`})
AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC
LIMIT 1
`)
if (!rows[0]) return null
const r = rows[0] as Record<string, unknown>
return {
playerName: r.player_name,
goals: r.goals,
penalties: r.penalties,
ownGoals: r.own_goals,
tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
}
},
async hatTricks(_: unknown, { year }: { year?: number }) {
const conditions = year ? sql`AND m.tournament_year = ${year}` : sql``
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
g.match_id,
COUNT(*)::int AS goals,
m.tournament_year AS year,
m.round,
CASE WHEN m.team1_id = g.team_id THEN m.team2_id ELSE m.team1_id END AS opponent_id
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE m.is_quali_playoff = false ${conditions}
GROUP BY g.player_name, g.team_id, g.match_id, m.tournament_year, m.round, m.team1_id, m.team2_id
HAVING COUNT(*) >= 3
ORDER BY goals DESC, m.tournament_year DESC
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name,
year: r.year,
round: r.round,
goals: r.goals,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
opponent: r.opponent_id ? await getTeamById(r.opponent_id as number) : null,
})))
},
async groupStandings(_: unknown, { year }: { year: number }) {
try {
const rows = await db.select({
groupName: groupStandings.groupName,
pos: groupStandings.pos,
teamId: groupStandings.teamId,
played: groupStandings.played,
won: groupStandings.won,
drawn: groupStandings.drawn,
lost: groupStandings.lost,
goalsFor: groupStandings.goalsFor,
goalsAgainst: groupStandings.goalsAgainst,
goalDiff: groupStandings.goalDiff,
pts: groupStandings.pts,
}).from(groupStandings)
.where(eq(groupStandings.tournamentYear, year))
.orderBy(asc(groupStandings.groupName), asc(groupStandings.pos))
return Promise.all(rows.map(async r => ({
...r,
played: r.played ?? 0,
won: r.won ?? 0,
drawn: r.drawn ?? 0,
lost: r.lost ?? 0,
goalsFor: r.goalsFor ?? 0,
goalsAgainst: r.goalsAgainst ?? 0,
goalDiff: r.goalDiff ?? 0,
pts: r.pts ?? 0,
team: (await getTeamById(r.teamId))!,
})))
} catch (e) { if (isMissingTable(e)) return []; throw e }
},
async stadiums(_: unknown, { year }: { year?: number }) {
const rows = year
? await db.select().from(stadiums).where(eq(stadiums.tournamentYear, year))
: await db.select().from(stadiums).orderBy(asc(stadiums.name))
return rows.map(r => ({ ...r, matchCount: 0 }))
},
async squads(_: unknown, { year, team }: { year: number; team?: string }) {
const rows = await db.select().from(squads).where(eq(squads.tournamentYear, year)).orderBy(asc(squads.shirtNumber))
const filtered = team ? rows.filter(r => r.teamId !== null) : rows
return Promise.all(filtered.map(async r => {
const t = await getTeamById(r.teamId)
const dob = r.dateOfBirth
const age = dob ? Math.floor((Date.now() - new Date(dob).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : null
return { ...r, team: t!, age }
}))
},
async tournamentStats() {
const empty = { totalTournaments: 0, totalMatches: 0, totalGoals: 0, avgGoalsPerGame: null, mostGoalsInTournament: null, highestScoringMatch: null, biggestWin: null, mostTitles: null }
try {
const [totals] = await db.execute(sql`
SELECT
COUNT(DISTINCT t.year)::int AS total_tournaments,
COUNT(DISTINCT m.id)::int AS total_matches,
COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals,
ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals
FROM tournaments t
LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL
`)
const r = totals as Record<string, unknown>
return { ...empty, totalTournaments: r.total_tournaments, totalMatches: r.total_matches, totalGoals: r.total_goals, avgGoalsPerGame: r.avg_goals }
} catch (e) { if (isMissingTable(e)) return empty; throw e }
},
async goalsByMinute() {
const rows = await db.execute(sql`
SELECT
CASE
WHEN g.minute BETWEEN 0 AND 15 THEN '0-15'
WHEN g.minute BETWEEN 16 AND 30 THEN '16-30'
WHEN g.minute BETWEEN 31 AND 45 THEN '31-45'
WHEN g.minute BETWEEN 46 AND 60 THEN '46-60'
WHEN g.minute BETWEEN 61 AND 75 THEN '61-75'
WHEN g.minute BETWEEN 76 AND 90 THEN '76-90'
WHEN g.minute > 90 THEN '90+'
ELSE 'Unknown'
END AS bucket,
COUNT(*)::int AS count
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE g.minute IS NOT NULL AND m.is_quali_playoff = false
GROUP BY bucket
ORDER BY MIN(g.minute)
`)
return rows as unknown as { bucket: string; count: number }[]
},
async confederationStats() {
const rows = await db.execute(sql`
SELECT
t.confederation,
COUNT(DISTINCT m.tournament_year || '-' || t.id)::int AS appearances,
COUNT(DISTINCT CASE WHEN tr.winner = t.name THEN tr.year END)::int AS titles,
COALESCE(SUM(
CASE WHEN m.team1_id = t.id THEN COALESCE(m.score_ft_home, 0)
WHEN m.team2_id = t.id THEN COALESCE(m.score_ft_away, 0)
ELSE 0 END
), 0)::int AS total_goals
FROM teams t
JOIN matches m ON (m.team1_id = t.id OR m.team2_id = t.id) AND m.is_quali_playoff = false
JOIN tournaments tr ON tr.year = m.tournament_year
WHERE t.confederation IS NOT NULL
GROUP BY t.confederation
ORDER BY appearances DESC
`)
return (rows as Record<string, unknown>[]).map(r => ({
confederation: r.confederation,
appearances: r.appearances,
titles: r.titles,
totalGoals: r.total_goals,
}))
},
async biggestWins(_: unknown, { limit = 10 }: { limit?: number }) {
const rows = await db.select().from(matches)
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
.orderBy(desc(sql`ABS(${matches.scoreFtHome} - ${matches.scoreFtAway})`), desc(matches.date))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async highestScoringMatches(_: unknown, { limit = 10 }: { limit?: number }) {
const rows = await db.select().from(matches)
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
.orderBy(desc(sql`${matches.scoreFtHome} + ${matches.scoreFtAway}`), desc(matches.date))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async extraTimeStats() {
const [r] = await db.execute(sql`
SELECT
COUNT(*)::int AS total_knockout,
SUM(CASE WHEN score_et_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_et,
SUM(CASE WHEN score_p_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_pens
FROM matches
WHERE group_name IS NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
`)
const row = r as Record<string, number>
const total = row.total_knockout || 1
return {
totalKnockoutMatches: row.total_knockout,
wentToExtraTime: row.went_et,
wentToPenalties: row.went_pens,
extraTimePct: Math.round((row.went_et / total) * 100),
penaltiesPct: Math.round((row.went_pens / total) * 100),
}
},
async search(_: unknown, { query }: { query: string }) {
if (!query || query.trim().length < 2) return { tournaments: [], teams: [], players: [], matches: [] }
const q = `%${query.trim()}%`
const [tourRows, teamRows, playerRows, matchRows] = await Promise.all([
db.select().from(tournaments).where(ilike(tournaments.host, q)).limit(5),
db.select().from(teams).where(or(ilike(teams.name, q), ilike(teams.fifaCode!, q))).limit(8),
db.execute(sql`
SELECT g.player_name, g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE LOWER(g.player_name) LIKE LOWER(${q}) AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC LIMIT 8
`),
db.select().from(matches)
.where(and(
or(ilike(matches.round, q), ilike(matches.groupName!, q)),
eq(matches.isQualiPlayoff, false),
))
.limit(5),
])
return {
tournaments: tourRows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })),
teams: teamRows.map(teamWithSlug),
players: await Promise.all(playerRows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
ownGoals: r.own_goals, tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
}))),
matches: await Promise.all(matchRows.map(hydrateMatch)),
}
},
},
Tournament: {
async topScorers(parent: { year: number }, { limit = 10 }: { limit?: number }) {
const rows = await db.execute(sql`
SELECT g.player_name, g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
1 AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE m.tournament_year = ${parent.year} AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC LIMIT ${limit}
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
ownGoals: r.own_goals, tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
})))
},
async matches(parent: { year: number }) {
const rows = await db.select().from(matches)
.where(and(eq(matches.tournamentYear, parent.year), eq(matches.isQualiPlayoff, false)))
.orderBy(asc(matches.date), asc(matches.id))
return Promise.all(rows.map(hydrateMatch))
},
},
Team: {
async stats(parent: { id: number; name: string }) {
const [r] = await db.execute(sql`
SELECT
COUNT(DISTINCT m.tournament_year)::int AS appearances,
SUM(CASE
WHEN m.team1_id = ${parent.id} AND m.score_ft_home > m.score_ft_away THEN 1
WHEN m.team2_id = ${parent.id} AND m.score_ft_away > m.score_ft_home THEN 1
ELSE 0 END)::int AS wins,
SUM(CASE WHEN m.score_ft_home = m.score_ft_away AND m.score_ft_home IS NOT NULL THEN 1 ELSE 0 END)::int AS draws,
SUM(CASE
WHEN m.team1_id = ${parent.id} AND m.score_ft_home < m.score_ft_away THEN 1
WHEN m.team2_id = ${parent.id} AND m.score_ft_away < m.score_ft_home THEN 1
ELSE 0 END)::int AS losses,
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0)
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0) ELSE 0 END)::int AS goals_for,
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0)
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0) ELSE 0 END)::int AS goals_against,
COUNT(DISTINCT CASE WHEN t.winner = ${parent.name} THEN t.year END)::int AS titles
FROM matches m
JOIN tournaments t ON t.year = m.tournament_year
WHERE (m.team1_id = ${parent.id} OR m.team2_id = ${parent.id})
AND m.is_quali_playoff = false
AND m.score_ft_home IS NOT NULL
`)
const row = r as Record<string, number>
const played = (row.wins ?? 0) + (row.draws ?? 0) + (row.losses ?? 0)
return {
appearances: row.appearances ?? 0,
wins: row.wins ?? 0,
draws: row.draws ?? 0,
losses: row.losses ?? 0,
goalsFor: row.goals_for ?? 0,
goalsAgainst: row.goals_against ?? 0,
goalDiff: (row.goals_for ?? 0) - (row.goals_against ?? 0),
titles: row.titles ?? 0,
winPct: played > 0 ? Math.round(((row.wins ?? 0) / played) * 100) : 0,
}
},
},
}