import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { logger } from "~/lib/logger"; export type GroupStageMatch = typeof schema.groupStageMatches.$inferSelect; export interface GroupStandingsRow { participantId: string; participantName: string; played: number; wins: number; draws: number; losses: number; gf: number; // goals for ga: number; // goals against gd: number; // goal difference points: number; } export interface UpcomingGroupMatch { matchId: string; groupId: string; groupName: string; sportsSeasonId: string; matchday: number; scheduledAt: string; // ISO string isComplete: boolean; participant1: { id: string; name: string }; participant2: { id: string; name: string }; participant1Score: number | null; participant2Score: number | null; } interface MatchInput { participant1Id: string; participant2Id: string; matchday: number; scheduledAt?: Date | null; } /** * Create the 6 round-robin matches for a tournament group. * Matches are created without scores (to be filled in as the tournament progresses). */ export async function createGroupStageMatches( groupId: string, matches: MatchInput[] ): Promise { const db = database(); return await db .insert(schema.groupStageMatches) .values( matches.map((m) => ({ tournamentGroupId: groupId, participant1Id: m.participant1Id, participant2Id: m.participant2Id, matchday: m.matchday, scheduledAt: m.scheduledAt ?? null, })) ) .returning(); } /** * Generate all 6 round-robin pairings for 4 participants in a group. * Uses a fixed round-robin schedule (matchdays 1, 2, 3). * * Standard 4-team round-robin schedule: * Matchday 1: 1v4, 2v3 * Matchday 2: 1v3, 4v2 * Matchday 3: 1v2, 3v4 */ export function generateRoundRobinPairings( participantIds: string[] ): MatchInput[] { if (participantIds.length !== 4) { throw new Error("Round-robin generation requires exactly 4 participants"); } const [p1, p2, p3, p4] = participantIds; return [ { participant1Id: p1, participant2Id: p4, matchday: 1 }, { participant1Id: p2, participant2Id: p3, matchday: 1 }, { participant1Id: p1, participant2Id: p3, matchday: 2 }, { participant1Id: p4, participant2Id: p2, matchday: 2 }, { participant1Id: p1, participant2Id: p2, matchday: 3 }, { participant1Id: p3, participant2Id: p4, matchday: 3 }, ]; } /** * Record the result of a group stage match and mark it complete. */ export async function updateGroupStageMatchResult( matchId: string, participant1Score: number, participant2Score: number ): Promise { const db = database(); const [updated] = await db .update(schema.groupStageMatches) .set({ participant1Score, participant2Score, isComplete: true, updatedAt: new Date(), }) .where(eq(schema.groupStageMatches.id, matchId)) .returning(); return updated; } /** * Update the scheduled time of a group stage match. */ export async function updateGroupStageMatchSchedule( matchId: string, scheduledAt: Date | null ): Promise { const db = database(); const [updated] = await db .update(schema.groupStageMatches) .set({ scheduledAt, updatedAt: new Date() }) .where(eq(schema.groupStageMatches.id, matchId)) .returning(); return updated; } /** * Fetch all matches for a group, with participant info. */ export async function findMatchesByGroupId(groupId: string) { const db = database(); return await db.query.groupStageMatches.findMany({ where: eq(schema.groupStageMatches.tournamentGroupId, groupId), orderBy: [ asc(schema.groupStageMatches.matchday), asc(schema.groupStageMatches.createdAt), ], with: { participant1: true, participant2: true, }, }); } /** * Fetch all matches for multiple groups in a single query. * Returns a Map from groupId → matches (ordered by scheduledAt, createdAt). * Use this instead of calling findMatchesByGroupId N times. */ export async function findMatchesByGroupIds( groupIds: string[] ): Promise>>> { if (groupIds.length === 0) return new Map(); const db = database(); const rows = await db.query.groupStageMatches.findMany({ where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds), orderBy: [ sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`, asc(schema.groupStageMatches.createdAt), ], with: { participant1: true, participant2: true, }, }); const result = new Map(); for (const groupId of groupIds) result.set(groupId, []); for (const row of rows) { result.get(row.tournamentGroupId)?.push(row); } return result; } /** * Fetch all group stage matches for a scoring event (all groups combined). */ export async function findMatchesByEventId(eventId: string) { const db = database(); const groups = await db.query.tournamentGroups.findMany({ where: eq(schema.tournamentGroups.scoringEventId, eventId), with: { matches: { orderBy: [ sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`, asc(schema.groupStageMatches.createdAt), ], with: { participant1: true, participant2: true, }, }, }, }); return groups; } /** * Compute standings for a group from match results. * Sorted by: points desc → goal difference desc → goals for desc → name asc. */ export function computeGroupStandings( members: Array<{ participantId: string; participantName: string }>, matches: Array<{ participant1Id: string; participant2Id: string; participant1Score: number | null; participant2Score: number | null; isComplete: boolean; }> ): GroupStandingsRow[] { const stats = new Map(); for (const m of members) { stats.set(m.participantId, { participantId: m.participantId, participantName: m.participantName, played: 0, wins: 0, draws: 0, losses: 0, gf: 0, ga: 0, gd: 0, points: 0, }); } for (const match of matches) { if (!match.isComplete) continue; if (match.participant1Score === null || match.participant2Score === null) { logger.warn( `[computeGroupStandings] Match between ${match.participant1Id} and ${match.participant2Id} is marked complete but has null scores — skipping` ); continue; } const p1 = stats.get(match.participant1Id); const p2 = stats.get(match.participant2Id); if (!p1 || !p2) continue; const s1 = match.participant1Score; const s2 = match.participant2Score; p1.played++; p2.played++; p1.gf += s1; p1.ga += s2; p2.gf += s2; p2.ga += s1; p1.gd = p1.gf - p1.ga; p2.gd = p2.gf - p2.ga; if (s1 > s2) { p1.wins++; p1.points += 3; p2.losses++; } else if (s2 > s1) { p2.wins++; p2.points += 3; p1.losses++; } else { p1.draws++; p2.draws++; p1.points++; p2.points++; } } return [...stats.values()].toSorted((a, b) => { if (b.points !== a.points) return b.points - a.points; if (b.gd !== a.gd) return b.gd - a.gd; if (b.gf !== a.gf) return b.gf - a.gf; return a.participantName.localeCompare(b.participantName); }); } /** * Get upcoming group stage matches (within a date window) for specific participants. * Used for calendar/schedule display on league home and team pages. */ export async function getUpcomingGroupStageMatchesForParticipants( sportsSeasonId: string, participantIds: string[], dateFrom: Date, dateTo: Date ): Promise { if (participantIds.length === 0) return []; const db = database(); const rows = await db .select({ matchId: schema.groupStageMatches.id, groupId: schema.tournamentGroups.id, groupName: schema.tournamentGroups.groupName, sportsSeasonId: schema.scoringEvents.sportsSeasonId, matchday: schema.groupStageMatches.matchday, scheduledAt: schema.groupStageMatches.scheduledAt, isComplete: schema.groupStageMatches.isComplete, participant1Id: schema.groupStageMatches.participant1Id, participant2Id: schema.groupStageMatches.participant2Id, participant1Score: schema.groupStageMatches.participant1Score, participant2Score: schema.groupStageMatches.participant2Score, }) .from(schema.groupStageMatches) .innerJoin( schema.tournamentGroups, eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id) ) .innerJoin( schema.scoringEvents, eq(schema.tournamentGroups.scoringEventId, schema.scoringEvents.id) ) .where( and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), or( inArray(schema.groupStageMatches.participant1Id, participantIds), inArray(schema.groupStageMatches.participant2Id, participantIds) ), and( gte(schema.groupStageMatches.scheduledAt, dateFrom), lte(schema.groupStageMatches.scheduledAt, dateTo) ) ) ) .orderBy(asc(schema.groupStageMatches.scheduledAt)); if (rows.length === 0) return []; // Load participant names for all involved participants const allParticipantIds = [ ...new Set(rows.flatMap((r) => [r.participant1Id, r.participant2Id])), ]; const participantRows = await db.query.seasonParticipants.findMany({ where: inArray(schema.seasonParticipants.id, allParticipantIds), }); const participantMap = new Map(participantRows.map((p) => [p.id, p])); return rows .filter((r) => r.scheduledAt !== null) .map((r) => { const p1 = participantMap.get(r.participant1Id); const p2 = participantMap.get(r.participant2Id); return { matchId: r.matchId, groupId: r.groupId, groupName: r.groupName, sportsSeasonId: r.sportsSeasonId, matchday: r.matchday, scheduledAt: r.scheduledAt?.toISOString() ?? "", isComplete: r.isComplete, participant1: { id: r.participant1Id, name: p1?.name ?? "Unknown" }, participant2: { id: r.participant2Id, name: p2?.name ?? "Unknown" }, participant1Score: r.participant1Score, participant2Score: r.participant2Score, }; }); }