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:
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from './schema'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
|
||||
|
||||
const client = postgres(connectionString, { max: 10 })
|
||||
export const db = drizzle(client, { schema })
|
||||
|
||||
export * from './schema'
|
||||
@@ -0,0 +1,91 @@
|
||||
import { pgTable, serial, integer, text, boolean, numeric, date, primaryKey } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const tournaments = pgTable('tournaments', {
|
||||
year: integer('year').primaryKey(),
|
||||
host: text('host').notNull(),
|
||||
winner: text('winner'),
|
||||
runnerUp: text('runner_up'),
|
||||
thirdPlace: text('third_place'),
|
||||
fourthPlace: text('fourth_place'),
|
||||
teamsCount: integer('teams_count'),
|
||||
matchesCount: integer('matches_count'),
|
||||
totalGoals: integer('total_goals'),
|
||||
avgGoalsPerGame: numeric('avg_goals_per_game', { precision: 4, scale: 2 }),
|
||||
})
|
||||
|
||||
export const teams = pgTable('teams', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: text('name').unique().notNull(),
|
||||
iso2: text('iso2'),
|
||||
fifaCode: text('fifa_code'),
|
||||
continent: text('continent'),
|
||||
confederation: text('confederation'),
|
||||
})
|
||||
|
||||
export const stadiums = pgTable('stadiums', {
|
||||
id: serial('id').primaryKey(),
|
||||
tournamentYear: integer('tournament_year'),
|
||||
name: text('name').notNull(),
|
||||
city: text('city'),
|
||||
countryCode: text('country_code'),
|
||||
capacity: integer('capacity'),
|
||||
timezone: text('timezone'),
|
||||
coordinates: text('coordinates'),
|
||||
})
|
||||
|
||||
export const matches = pgTable('matches', {
|
||||
id: serial('id').primaryKey(),
|
||||
tournamentYear: integer('tournament_year').notNull(),
|
||||
round: text('round').notNull(),
|
||||
groupName: text('group_name'),
|
||||
date: date('date'),
|
||||
timeLocal: text('time_local'),
|
||||
stadiumId: integer('stadium_id'),
|
||||
team1Id: integer('team1_id').notNull(),
|
||||
team2Id: integer('team2_id').notNull(),
|
||||
scoreFtHome: integer('score_ft_home'),
|
||||
scoreFtAway: integer('score_ft_away'),
|
||||
scoreHtHome: integer('score_ht_home'),
|
||||
scoreHtAway: integer('score_ht_away'),
|
||||
scoreEtHome: integer('score_et_home'),
|
||||
scoreEtAway: integer('score_et_away'),
|
||||
scorePHome: integer('score_p_home'),
|
||||
scorePAway: integer('score_p_away'),
|
||||
isQualiPlayoff: boolean('is_quali_playoff').default(false),
|
||||
})
|
||||
|
||||
export const goals = pgTable('goals', {
|
||||
id: serial('id').primaryKey(),
|
||||
matchId: integer('match_id').notNull(),
|
||||
teamId: integer('team_id').notNull(),
|
||||
playerName: text('player_name').notNull(),
|
||||
minute: integer('minute'),
|
||||
minuteOffset: integer('minute_offset').default(0),
|
||||
isPenalty: boolean('is_penalty').default(false),
|
||||
isOwnGoal: boolean('is_own_goal').default(false),
|
||||
})
|
||||
|
||||
export const groupStandings = pgTable('group_standings', {
|
||||
tournamentYear: integer('tournament_year').notNull(),
|
||||
groupName: text('group_name').notNull(),
|
||||
teamId: integer('team_id').notNull(),
|
||||
pos: integer('pos'),
|
||||
played: integer('played').default(0),
|
||||
won: integer('won').default(0),
|
||||
drawn: integer('drawn').default(0),
|
||||
lost: integer('lost').default(0),
|
||||
goalsFor: integer('goals_for').default(0),
|
||||
goalsAgainst: integer('goals_against').default(0),
|
||||
goalDiff: integer('goal_diff').default(0),
|
||||
pts: integer('pts').default(0),
|
||||
}, (t) => [primaryKey({ columns: [t.tournamentYear, t.groupName, t.teamId] })])
|
||||
|
||||
export const squads = pgTable('squads', {
|
||||
id: serial('id').primaryKey(),
|
||||
tournamentYear: integer('tournament_year').notNull(),
|
||||
teamId: integer('team_id').notNull(),
|
||||
playerName: text('player_name').notNull(),
|
||||
shirtNumber: integer('shirt_number'),
|
||||
position: text('position'),
|
||||
dateOfBirth: date('date_of_birth'),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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!
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,77 @@
|
||||
export const TEAM_ISO: Record<string, string> = {
|
||||
// A
|
||||
'Afghanistan': 'af', 'Albania': 'al', 'Algeria': 'dz', 'Angola': 'ao',
|
||||
'Argentina': 'ar', 'Armenia': 'am', 'Australia': 'au', 'Austria': 'at',
|
||||
// B
|
||||
'Bahrain': 'bh', 'Belgium': 'be', 'Bolivia': 'bo',
|
||||
'Bosnia & Herzegovina': 'ba', 'Bosnia and Herzegovina': 'ba', 'Brazil': 'br',
|
||||
'Bulgaria': 'bg', 'Burkina Faso': 'bf',
|
||||
// C
|
||||
'Cameroon': 'cm', 'Canada': 'ca', 'Cape Verde': 'cv', 'Chile': 'cl',
|
||||
'China': 'cn', "China PR": 'cn', 'Colombia': 'co', 'Costa Rica': 'cr',
|
||||
'Croatia': 'hr', 'Cuba': 'cu', 'Curaçao': 'cw', 'Curacao': 'cw',
|
||||
'Cyprus': 'cy', 'Czech Republic': 'cz', 'Czechia': 'cz',
|
||||
// D
|
||||
'Denmark': 'dk', 'DR Congo': 'cd', 'Dutch East Indies': 'id',
|
||||
// E
|
||||
'Ecuador': 'ec', 'Egypt': 'eg', 'El Salvador': 'sv',
|
||||
'England': 'gb-eng', 'Estonia': 'ee', 'Ethiopia': 'et',
|
||||
// F
|
||||
'Finland': 'fi', 'France': 'fr',
|
||||
// G
|
||||
'Gabon': 'ga', 'Germany': 'de', 'West Germany': 'de', 'East Germany': 'de',
|
||||
'Ghana': 'gh', 'Greece': 'gr', 'Guatemala': 'gt', 'Guinea': 'gn',
|
||||
// H
|
||||
'Haiti': 'ht', 'Honduras': 'hn', 'Hungary': 'hu',
|
||||
// I
|
||||
'Iceland': 'is', 'India': 'in', 'Indonesia': 'id', 'Iran': 'ir',
|
||||
'Iraq': 'iq', 'Ireland': 'ie', 'Republic of Ireland': 'ie',
|
||||
'Israel': 'il', 'Italy': 'it', 'Ivory Coast': 'ci', "Côte d'Ivoire": 'ci',
|
||||
// J
|
||||
'Jamaica': 'jm', 'Japan': 'jp', 'Jordan': 'jo',
|
||||
// K
|
||||
'Kazakhstan': 'kz', 'Kenya': 'ke', 'Kuwait': 'kw',
|
||||
// L
|
||||
'Latvia': 'lv', 'Lebanon': 'lb', 'Liberia': 'lr', 'Lithuania': 'lt',
|
||||
// M
|
||||
'Mali': 'ml', 'Malta': 'mt', 'Mexico': 'mx', 'Moldova': 'md',
|
||||
'Montenegro': 'me', 'Morocco': 'ma', 'Mozambique': 'mz',
|
||||
// N
|
||||
'Netherlands': 'nl', 'New Zealand': 'nz', 'Nigeria': 'ng',
|
||||
'North Korea': 'kp', "Korea DPR": 'kp', 'Northern Ireland': 'gb-nir',
|
||||
'North Macedonia': 'mk', 'Norway': 'no',
|
||||
// O
|
||||
'Oman': 'om',
|
||||
// P
|
||||
'Panama': 'pa', 'Paraguay': 'py', 'Peru': 'pe', 'Philippines': 'ph',
|
||||
'Poland': 'pl', 'Portugal': 'pt',
|
||||
// Q
|
||||
'Qatar': 'qa',
|
||||
// R
|
||||
'Romania': 'ro', 'Russia': 'ru', 'Soviet Union': 'su',
|
||||
// S
|
||||
'Saudi Arabia': 'sa', 'Scotland': 'gb-sct', 'Senegal': 'sn',
|
||||
'Serbia': 'rs', 'Yugoslavia': 'yu', 'Slovakia': 'sk', 'Slovenia': 'si',
|
||||
'Somalia': 'so', 'South Africa': 'za', 'South Korea': 'kr',
|
||||
'Korea Republic': 'kr', 'Spain': 'es', 'Sweden': 'se', 'Switzerland': 'ch',
|
||||
// T
|
||||
'Taiwan': 'tw', 'Tanzania': 'tz', 'Thailand': 'th', 'Togo': 'tg',
|
||||
'Trinidad and Tobago': 'tt', 'Tunisia': 'tn', 'Turkey': 'tr',
|
||||
// U
|
||||
'UAE': 'ae', 'United Arab Emirates': 'ae', 'Uganda': 'ug', 'Ukraine': 'ua',
|
||||
'Uruguay': 'uy', 'USA': 'us', 'United States': 'us', 'Uzbekistan': 'uz',
|
||||
// V
|
||||
'Venezuela': 've', 'Vietnam': 'vn',
|
||||
// W
|
||||
'Wales': 'gb-wls',
|
||||
// Z
|
||||
'Zambia': 'zm', 'Zimbabwe': 'zw',
|
||||
}
|
||||
|
||||
export function getIso(teamName: string): string {
|
||||
return TEAM_ISO[teamName] ?? teamName.toLowerCase().replace(/\s+/g, '-').substring(0, 2)
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
Reference in New Issue
Block a user