9ce2a4e27c
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>
490 lines
22 KiB
TypeScript
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,
|
|
}
|
|
},
|
|
},
|
|
}
|