2026-06-14 15:36:44 +02:00
|
|
|
import { db } from '@/lib/db'
|
|
|
|
|
import { tournaments, teams, matches, goals, groupStandings, stadiums, squads } from '@/lib/db/schema'
|
|
|
|
|
import { slugify, getIso } from '@/lib/iso-codes'
|
2026-06-14 21:26:25 +02:00
|
|
|
import { eq, and, desc, asc, sql, ilike, or, isNotNull, lt, lte, gt, gte } from 'drizzle-orm'
|
2026-06-14 15:36:44 +02:00
|
|
|
|
|
|
|
|
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()
|
2026-06-14 17:24:59 +02:00
|
|
|
if (now.toISOString().slice(0, 10) !== dateStr) return false
|
2026-06-14 15:36:44 +02:00
|
|
|
if (!timeStr) return true
|
2026-06-14 17:24:59 +02:00
|
|
|
// 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)
|
2026-06-14 15:36:44 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 17:01:06 +02:00
|
|
|
function isMissingTable(e: unknown): boolean {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
|
|
|
return msg.includes('relation') && msg.includes('does not exist')
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 15:36:44 +02:00
|
|
|
export const resolvers = {
|
|
|
|
|
Query: {
|
|
|
|
|
async tournaments() {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async tournament(_: unknown, { year }: { year: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
2026-06-14 21:07:56 +02:00
|
|
|
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean; teamId?: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
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))
|
2026-06-14 21:07:56 +02:00
|
|
|
if (args.teamId) conditions.push(or(eq(matches.team1Id, args.teamId), eq(matches.team2Id, args.teamId))!)
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async match(_: unknown, { id }: { id: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async liveMatches() {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
try {
|
|
|
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
|
const rows = await db.select().from(matches)
|
|
|
|
|
.where(and(
|
2026-06-14 21:26:25 +02:00
|
|
|
lte(matches.date, today),
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
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),
|
|
|
|
|
))
|
2026-06-15 18:14:53 +02:00
|
|
|
.orderBy(asc(matches.date), sql`SPLIT_PART(${matches.timeLocal}, ' ', 1) ASC NULLS LAST`, asc(matches.id))
|
2026-06-14 17:01:06 +02:00
|
|
|
.limit(limit)
|
|
|
|
|
return Promise.all(rows.map(hydrateMatch))
|
|
|
|
|
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async teams() {
|
2026-06-14 17:01:06 +02:00
|
|
|
try {
|
|
|
|
|
const rows = await db.select().from(teams).orderBy(asc(teams.name))
|
|
|
|
|
return rows.map(teamWithSlug)
|
|
|
|
|
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
async team(_: unknown, { slug }: { slug: string }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
2026-06-14 21:11:16 +02:00
|
|
|
async topScorers(_: unknown, { year, limit = 20, teamId }: { year?: number; limit?: number; teamId?: number }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
try {
|
2026-06-14 21:11:16 +02:00
|
|
|
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
|
|
|
|
|
`
|
2026-06-14 15:36:44 +02:00
|
|
|
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,
|
|
|
|
|
})))
|
2026-06-14 17:01:06 +02:00
|
|
|
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
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 }) {
|
2026-06-14 17:01:06 +02:00
|
|
|
try {
|
2026-06-14 15:36:44 +02:00
|
|
|
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))!,
|
|
|
|
|
})))
|
2026-06-14 17:01:06 +02:00
|
|
|
} catch (e) { if (isMissingTable(e)) return []; throw e }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
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() {
|
2026-06-14 17:01:06 +02:00
|
|
|
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 }
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
`)
|
2026-06-14 17:06:22 +02:00
|
|
|
return (rows as Record<string, unknown>[]).map(r => ({
|
|
|
|
|
confederation: r.confederation,
|
|
|
|
|
appearances: r.appearances,
|
|
|
|
|
titles: r.titles,
|
|
|
|
|
totalGoals: r.total_goals,
|
|
|
|
|
}))
|
2026-06-14 15:36:44 +02:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|