feat: initial commit — World Cup stats app with pnpm, Traefik, Docker

Full-stack World Cup web app (1930–2026):
- Next.js 16 + TailwindCSS 4 + GraphQL Yoga + Apollo Client 4 + Drizzle + PostgreSQL 16
- 23 tournaments synced from openfootball/worldcup.json (matches, goals, teams, stadiums, squads, standings)
- Pages: home (live), groups, stats, history, search, /tournaments/[year], /teams/[slug], /players/[name]
- Live match detection via isLive() + Apollo 60 s poll
- pnpm with node-linker=hoisted for Docker compatibility
- docker-compose.yml with Traefik labels (HTTPS redirect, TLS, security middleware)
- docker-compose.dev.yml for local dev (DB only, port 5432 exposed)
- Dockerfile: multi-stage pnpm build, standalone Next.js output, sync script bundled
- .env.example with all required variables documented
- Comprehensive README with local dev, deployment, schema, and GraphQL API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:36:44 +02:00
commit 58b4114159
46 changed files with 9040 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let client: InstanceType<typeof ApolloClient> | null = null
function createClient() {
return new ApolloClient({
link: new HttpLink({ uri: '/api/graphql' }),
cache: new InMemoryCache(),
defaultOptions: { watchQuery: { fetchPolicy: 'cache-and-network' } },
})
}
export function getApolloClient() {
if (typeof window === 'undefined') return createClient()
if (!client) client = createClient()
return client
}
+12
View File
@@ -0,0 +1,12 @@
'use client'
/* Apollo Client v4 defaults TData to unknown — wrap to restore convenient any typing */
import { useQuery as _useQuery } from '@apollo/client/react'
import type { DocumentNode } from '@apollo/client/core'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useQuery(query: DocumentNode, options?: Record<string, unknown>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return _useQuery<Record<string, any>>(query, options)
}
export { gql } from '@apollo/client/core'
+458
View File
@@ -0,0 +1,458 @@
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, 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()
const today = now.toISOString().slice(0, 10)
if (dateStr !== today) return false
if (!timeStr) return true
// parse time like "20:00 CET" or "13:00 UTC-6"
const timePart = timeStr.split(' ')[0]
const [h, m] = timePart.split(':').map(Number)
if (isNaN(h)) return true
const kickoff = new Date(now)
kickoff.setHours(h, m ?? 0, 0, 0)
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,
}
}
export const resolvers = {
Query: {
async tournaments() {
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
},
async tournament(_: unknown, { year }: { year: number }) {
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 }
},
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) {
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))
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))
},
async match(_: unknown, { id }: { id: number }) {
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
return rows[0] ? hydrateMatch(rows[0]) : null
},
async liveMatches() {
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)
},
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(
lt(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))
},
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
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), asc(matches.id))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async teams() {
const rows = await db.select().from(teams).orderBy(asc(teams.name))
return rows.map(teamWithSlug)
},
async team(_: unknown, { slug }: { slug: string }) {
const rows = await db.select().from(teams)
const found = rows.find(r => slugify(r.name) === slug)
return found ? teamWithSlug(found) : null
},
async topScorers(_: unknown, { year, limit = 20 }: { year?: number; limit?: number }) {
const conditions = year
? sql`AND m.tournament_year = ${year} AND m.is_quali_playoff = false`
: 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,
})))
},
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 }) {
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))!,
})))
},
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 [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 {
totalTournaments: r.total_tournaments,
totalMatches: r.total_matches,
totalGoals: r.total_goals,
avgGoalsPerGame: r.avg_goals,
mostGoalsInTournament: null,
highestScoringMatch: null,
biggestWin: null,
mostTitles: null,
}
},
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
},
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,
}
},
},
}
+199
View File
@@ -0,0 +1,199 @@
export const typeDefs = /* GraphQL */ `
type Tournament {
year: Int!
host: String!
winner: String
runnerUp: String
thirdPlace: String
fourthPlace: String
teamsCount: Int
matchesCount: Int
totalGoals: Int
avgGoalsPerGame: Float
topScorers: [ScorerEntry!]!
matches: [Match!]!
}
type Team {
id: Int!
name: String!
slug: String!
iso2: String
fifaCode: String
continent: String
confederation: String
stats: TeamStats
}
type TeamStats {
appearances: Int!
wins: Int!
draws: Int!
losses: Int!
goalsFor: Int!
goalsAgainst: Int!
goalDiff: Int!
titles: Int!
winPct: Float!
}
type Stadium {
id: Int!
tournamentYear: Int
name: String!
city: String
countryCode: String
capacity: Int
timezone: String
coordinates: String
matchCount: Int
}
type Match {
id: Int!
year: Int!
round: String!
group: String
date: String
time: String
stadium: String
team1: Team!
team2: Team!
scoreFt: [Int!]
scoreHt: [Int!]
scoreEt: [Int!]
scoreP: [Int!]
goals: [Goal!]!
isLive: Boolean!
isQualiPlayoff: Boolean!
margin: Int
totalGoals: Int
}
type Goal {
id: Int!
team: Team!
playerName: String!
minute: Int
minuteOffset: Int
isPenalty: Boolean!
isOwnGoal: Boolean!
}
type ScorerEntry {
playerName: String!
team: Team
goals: Int!
penalties: Int!
ownGoals: Int!
tournaments: Int!
}
type GroupStanding {
groupName: String!
pos: Int
team: Team!
played: Int!
won: Int!
drawn: Int!
lost: Int!
goalsFor: Int!
goalsAgainst: Int!
goalDiff: Int!
pts: Int!
}
type SquadPlayer {
playerName: String!
shirtNumber: Int
position: String
dateOfBirth: String
team: Team!
age: Int
}
type GlobalStats {
totalTournaments: Int!
totalMatches: Int!
totalGoals: Int!
avgGoalsPerGame: Float!
mostGoalsInTournament: TournamentGoalRecord
highestScoringMatch: Match
biggestWin: Match
mostTitles: ScorerEntry
}
type TournamentGoalRecord {
year: Int!
host: String!
totalGoals: Int!
}
type HatTrick {
playerName: String!
team: Team
year: Int!
round: String!
opponent: Team
goals: Int!
}
type MinuteBucket {
bucket: String!
count: Int!
}
type ConfederationStat {
confederation: String!
appearances: Int!
titles: Int!
totalGoals: Int!
}
type SearchResults {
tournaments: [Tournament!]!
teams: [Team!]!
players: [ScorerEntry!]!
matches: [Match!]!
}
type Query {
tournaments: [Tournament!]!
tournament(year: Int!): Tournament
matches(year: Int, group: String, round: String, isQuali: Boolean): [Match!]!
match(id: Int!): Match
liveMatches: [Match!]!
recentMatches(limit: Int): [Match!]!
upcomingMatches(limit: Int): [Match!]!
teams: [Team!]!
team(slug: String!): Team
topScorers(year: Int, limit: Int): [ScorerEntry!]!
player(name: String!): ScorerEntry
hatTricks(year: Int): [HatTrick!]!
groupStandings(year: Int!): [GroupStanding!]!
stadiums(year: Int): [Stadium!]!
squads(year: Int!, team: String): [SquadPlayer!]!
tournamentStats: GlobalStats!
goalsByMinute: [MinuteBucket!]!
confederationStats: [ConfederationStat!]!
biggestWins(limit: Int): [Match!]!
highestScoringMatches(limit: Int): [Match!]!
extraTimeStats: ExtraTimeStats!
search(query: String!): SearchResults!
}
type ExtraTimeStats {
totalKnockoutMatches: Int!
wentToExtraTime: Int!
wentToPenalties: Int!
extraTimePct: Float!
penaltiesPct: Float!
}
`