import { Injectable, Logger } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; import { PrismaService } from "../../database/prisma.service"; import { Sport, MatchQueryDto, LeagueWithMatchesDto, ActiveLeagueDto, } from "./dto"; import { Prisma } from "@prisma/client"; import { FINISHED_STATE_VALUES_FOR_DB, FINISHED_STATUS_VALUES_FOR_DB, LIVE_STATE_VALUES_FOR_DB, LIVE_STATUS_VALUES_FOR_DB, getDisplayMatchStatus, } from "../../common/utils/match-status.util"; @Injectable() export class MatchesService { private readonly logger = new Logger(MatchesService.name); private topLeagueIds: string[] = []; constructor(private readonly prisma: PrismaService) { this.loadTopLeagues(); } private loadTopLeagues() { try { const topLeaguesPath = path.join(process.cwd(), "top_leagues.json"); if (fs.existsSync(topLeaguesPath)) { this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8")); this.logger.log( `Loaded ${this.topLeagueIds.length} top leagues for filtering.`, ); } } catch (e) { this.logger.warn(`Failed to load top_leagues.json: ${e.message}`); } } private getLiveFilter(): Prisma.LiveMatchWhereInput { return { OR: [ { status: { in: LIVE_STATUS_VALUES_FOR_DB, }, }, { state: { in: LIVE_STATE_VALUES_FOR_DB, }, }, ], }; } private getFinishedFilter(): Prisma.LiveMatchWhereInput { return { OR: [ { status: { in: FINISHED_STATUS_VALUES_FOR_DB, }, }, { state: { in: FINISHED_STATE_VALUES_FOR_DB, }, }, { AND: [ { scoreHome: { not: null } }, { scoreAway: { not: null } }, { NOT: this.getLiveFilter(), }, ], }, ], }; } private getUpcomingFilter( fromTimestampMs: number, ): Prisma.LiveMatchWhereInput { return { AND: [ { mstUtc: { gte: BigInt(fromTimestampMs), }, }, { NOT: { OR: [this.getLiveFilter(), this.getFinishedFilter()], }, }, ], }; } private getBrowseFilter(fromTimestampMs: number): Prisma.LiveMatchWhereInput { return { AND: [ { mstUtc: { gte: BigInt(fromTimestampMs), }, }, { NOT: this.getFinishedFilter(), }, ], }; } /** * Find matches by query criteria */ async findMatches(options: MatchQueryDto): Promise { const { sport, limit = 50, leagueId, status, date, team, dateRange, } = options; // Build where conditions const where: Prisma.LiveMatchWhereInput = { sport: sport as any, }; const andConditions: Prisma.LiveMatchWhereInput[] = []; if (leagueId) { where.leagueId = leagueId; } else if (status === "LIVE" && this.topLeagueIds.length > 0) { // Filter live matches by top leagues by default if no leagueId is provided where.leagueId = { in: this.topLeagueIds }; } if (status === "LIVE") { andConditions.push(this.getLiveFilter()); } else if (status === "UPCOMING" || status === "NOT_STARTED") { andConditions.push(this.getUpcomingFilter(Date.now())); } else if (status === "FINISHED") { andConditions.push(this.getFinishedFilter()); } else if (status) { where.status = status; } // Date filter if (date) { const d = new Date(date); const startOfDay = new Date(d); startOfDay.setUTCHours(0, 0, 0, 0); const endOfDay = new Date(d); endOfDay.setUTCHours(23, 59, 59, 999); where.mstUtc = { gte: BigInt(startOfDay.getTime()), lte: BigInt(endOfDay.getTime()), }; } else if (dateRange) { where.mstUtc = { gte: BigInt(new Date(dateRange.from).getTime()), lte: BigInt(new Date(dateRange.to).getTime()), }; } // Team filter if (team) { if (team.role === "home") { where.homeTeamId = team.id; } else if (team.role === "away") { where.awayTeamId = team.id; } else { andConditions.push({ OR: [{ homeTeamId: team.id }, { awayTeamId: team.id }], }); } } // Default date filter: From today onwards if no specific filter if (!date && !dateRange && !status) { const today = new Date(); today.setUTCHours(0, 0, 0, 0); // Start of today in UTC andConditions.push(this.getBrowseFilter(today.getTime())); } if (andConditions.length > 0) { where.AND = andConditions; } // Switch to live_matches table const matches = await this.prisma.liveMatch.findMany({ where, select: { id: true }, orderBy: { mstUtc: "asc" }, // Sort by nearest match first take: limit, }); return matches.map((m) => m.id); } /** * Find upcoming matches from the live matches table * Used for Coupon Generator when no specific matches are selected */ async findUpcomingMatches( sport: Sport, limit: number = 50, ): Promise { console.log(`[MatchesService] Finding upcoming matches for ${sport}`); const matches = await this.prisma.liveMatch.findMany({ where: { sport: sport as any, AND: [this.getUpcomingFilter(Date.now())], }, select: { id: true }, orderBy: { mstUtc: "asc" }, take: limit, }); console.log( `[MatchesService] Found ${matches.length} upcoming matches from live_matches`, ); return matches.map((m) => m.id); } async filterUpcomingMatchIds( matchIds: string[], sport: Sport, ): Promise { const uniqueIds = [...new Set(matchIds.filter((id) => !!id))]; if (uniqueIds.length === 0) { return []; } const matches = await this.prisma.liveMatch.findMany({ where: { id: { in: uniqueIds }, sport: sport as any, AND: [this.getUpcomingFilter(Date.now())], }, select: { id: true }, }); return matches.map((match) => match.id); } /** * Get matches structured by league (from live_matches table) */ async getMatchesAndStructureByIds( matchIds: string[], sport: Sport, ): Promise { if (!matchIds.length) return []; const matches = await this.prisma.liveMatch.findMany({ where: { id: { in: matchIds } }, include: { league: { include: { country: true, }, }, homeTeam: true, awayTeam: true, }, }); // Sort matches by time (ASC) before grouping to ensure correct order matches.sort((a, b) => Number(BigInt(a.mstUtc || 0) - BigInt(b.mstUtc || 0)), ); // Group by league const leaguesMap = new Map(); for (const match of matches) { const leagueId = match.leagueId || "unknown"; if (!leaguesMap.has(leagueId)) { leaguesMap.set(leagueId, { id: leagueId, name: match.league?.name || "Unknown League", code: match.league?.code || undefined, country: { id: match.league?.country?.id || "", name: match.league?.country?.name || "", flagUrl: match.league?.country?.flagUrl || undefined, }, sport: sport, matches: [], }); } const league = leaguesMap.get(leagueId)!; // Structure odds from JSON const structuredOdds: any[] = []; if ( match.odds && typeof match.odds === "object" && !Array.isArray(match.odds) ) { const oddsObj = match.odds as Record>; for (const [marketName, selections] of Object.entries(oddsObj)) { const structuredSelections: Record = {}; if (selections && typeof selections === "object") { for (const [selName, selOdd] of Object.entries(selections)) { structuredSelections[selName] = { odd: String(selOdd) }; } structuredOdds.push({ category_name: marketName, selections: structuredSelections, }); } } } // Map status for frontend const displayStatus = getDisplayMatchStatus({ state: match.state, status: match.status, substate: match.substate, scoreHome: match.scoreHome, scoreAway: match.scoreAway, }); league.matches.push({ id: match.id, matchName: match.matchName || `${match.homeTeam?.name} vs ${match.awayTeam?.name}`, matchSlug: match.matchSlug || undefined, mstUtc: Number(match.mstUtc), status: displayStatus, state: match.state || undefined, scoreHome: match.scoreHome ?? undefined, scoreAway: match.scoreAway ?? undefined, htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually htScoreAway: undefined, homeTeamName: match.homeTeam?.name || "Unknown", homeTeamLogo: match.homeTeamId ? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}` : undefined, awayTeamName: match.awayTeam?.name || "Unknown", awayTeamLogo: match.awayTeamId ? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}` : undefined, leagueName: match.league?.name, countryName: match.league?.country?.name, odds: structuredOdds, }); } return Array.from(leaguesMap.values()); } /** * Get active leagues with match counts */ async getActiveLeagues(sport: Sport): Promise { // Use raw query for complex aggregation const leagues = await this.prisma.$queryRaw` SELECT l.id, l.name, l.code, c.name as country_name, c.flag_url as country_flag, COUNT(lm.id)::int as match_count, COUNT(CASE WHEN lm.status IN ('LIVE', '1H', '2H', 'HT', '1Q', '2Q', '3Q', '4Q', 'Playing', 'Half Time') OR lm.state IN ('live', 'firsthalf', 'secondhalf') THEN 1 END)::int as live_count FROM live_matches lm JOIN leagues l ON lm.league_id = l.id LEFT JOIN countries c ON l.country_id = c.id WHERE lm.sport = ${sport} ${this.topLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.topLeagueIds)})` : Prisma.empty} GROUP BY l.id, l.name, l.code, c.name, c.flag_url ORDER BY l.name ASC `; // Priority sorting (Mackolik style) const PRIORITY = [ "Trendyol Süper Lig", "Süper Lig", "Trendyol 1. Lig", "1. Lig", "Premier Lig", "LaLiga", "Serie A", "Bundesliga", "Ligue 1", ]; return leagues .sort((a, b) => { const aIdx = PRIORITY.findIndex((p) => a.name?.includes(p)); const bIdx = PRIORITY.findIndex((p) => b.name?.includes(p)); const aPriority = aIdx === -1 ? 999 : aIdx; const bPriority = bIdx === -1 ? 999 : bIdx; if (aPriority !== bPriority) return aPriority - bPriority; return (a.name || "").localeCompare(b.name || ""); }) .map((l) => ({ id: l.id, name: l.name, code: l.code, countryName: l.country_name, countryFlag: l.country_flag, matchCount: l.match_count, liveCount: l.live_count, })); } /** * List matches with pagination */ async listMatches(sport: Sport, page: number = 1, limit: number = 20) { const skip = (page - 1) * limit; const [matches, total] = await Promise.all([ this.prisma.match.findMany({ where: { sport: sport as any }, include: { homeTeam: true, awayTeam: true, league: { include: { country: true }, }, }, orderBy: { mstUtc: "desc" }, skip, take: limit, }), this.prisma.match.count({ where: { sport: sport as any } }), ]); return { matches: matches.map((m) => ({ id: m.id, matchName: m.matchName, matchSlug: m.matchSlug, mstUtc: Number(m.mstUtc), scoreHome: m.scoreHome, scoreAway: m.scoreAway, status: m.status, homeTeamName: m.homeTeam?.name, homeTeamLogo: m.homeTeamId ? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}` : null, awayTeamName: m.awayTeam?.name, awayTeamLogo: m.awayTeamId ? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}` : null, leagueName: m.league?.name, countryName: m.league?.country?.name, })), total, page, totalPages: Math.ceil(total / limit), }; } private normalizeTeamStat(stat: any, sport?: string) { if (!stat) return null; const base = { id: stat.id, matchId: stat.matchId, teamId: stat.teamId, createdAt: stat.createdAt, }; if ((sport || "").toLowerCase() === "basketball") { return { ...base, points: stat.points, rebounds: stat.rebounds, assists: stat.assists, fgMade: stat.fgMade, fgAttempted: stat.fgAttempted, threePtMade: stat.threePtMade, threePtAttempted: stat.threePtAttempted, ftMade: stat.ftMade, ftAttempted: stat.ftAttempted, steals: stat.steals, blocks: stat.blocks, turnovers: stat.turnovers, q1Score: stat.q1Score, q2Score: stat.q2Score, q3Score: stat.q3Score, q4Score: stat.q4Score, otScore: stat.otScore, }; } return { ...base, possessionPercentage: stat.possessionPercentage, shotsOnTarget: stat.shotsOnTarget, shotsOffTarget: stat.shotsOffTarget, totalShots: stat.totalShots, totalPasses: stat.totalPasses, corners: stat.corners, fouls: stat.fouls, offsides: stat.offsides, }; } /** * Get full match details by ID */ async getMatchDetailsById(matchId: string) { let match: any = await this.prisma.match.findUnique({ where: { id: matchId }, include: { league: { include: { country: true } }, homeTeam: true, awayTeam: true, footballTeamStats: true, basketballTeamStats: true, playerParticipations: { include: { player: true }, orderBy: [{ isStarting: "desc" }, { position: "asc" }], }, playerEvents: { include: { player: true, assistPlayer: true, substitutedOut: true, }, orderBy: [{ periodId: "asc" }, { timeMinute: "asc" }], }, oddCategories: { include: { selections: true }, }, officials: true, }, }); if (!match) { // Try to find in LiveMatch table const liveMatch = await this.prisma.liveMatch.findUnique({ where: { id: matchId }, include: { league: { include: { country: true } }, homeTeam: true, awayTeam: true, }, }); if (liveMatch) { // Map liveMatch status const displayStatus = getDisplayMatchStatus({ state: liveMatch.state, status: liveMatch.status, substate: liveMatch.substate, scoreHome: liveMatch.scoreHome, scoreAway: liveMatch.scoreAway, }); match = { ...liveMatch, matchName: liveMatch.matchName || `${liveMatch.homeTeam?.name} vs ${liveMatch.awayTeam?.name}`, status: displayStatus, mstUtc: liveMatch.mstUtc, score: { home: liveMatch.scoreHome, away: liveMatch.scoreAway, }, date: new Date(Number(liveMatch.mstUtc)), // Fill missing relations with empty arrays teamStats: [], playerParticipations: (() => { const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = []; const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished"; if (!canTrustFeedLineups) { return parsed; } if (liveMatch.lineups && typeof liveMatch.lineups === 'object') { const lu = liveMatch.lineups as Record; const addPlayers = (teamLu: any, teamId: string | null) => { if (!teamLu || !teamId) return; if (teamLu.xi && Array.isArray(teamLu.xi)) { teamLu.xi.forEach((p: any) => { parsed.push({ teamId, isStarting: true, shirtNumber: p.shirtNumber || p.number, position: p.position || p.pos, player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' } }); }); } if (teamLu.subs && Array.isArray(teamLu.subs)) { teamLu.subs.forEach((p: any) => { parsed.push({ teamId, isStarting: false, shirtNumber: p.shirtNumber || p.number, position: p.position || p.pos, player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' } }); }); } }; addPlayers(lu.home, liveMatch.homeTeamId); addPlayers(lu.away, liveMatch.awayTeamId); } return parsed; })(), playerEvents: [], oddCategories: [], // Will handle odds parsing below officials: [], isLiveSource: true, // Flag to indicate source }; } } if (!match) return null; const detailDisplayStatus = getDisplayMatchStatus({ state: match.state, status: match.status, substate: match.substate, scoreHome: match.scoreHome, scoreAway: match.scoreAway, }); const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus); if (Array.isArray(match.playerParticipations)) { if (!canTrustStoredLineups) { match.playerParticipations = []; } const hasHomeLineup = match.playerParticipations.some( (p: any) => p.teamId === match.homeTeamId && p.isStarting, ); const hasAwayLineup = match.playerParticipations.some( (p: any) => p.teamId === match.awayTeamId && p.isStarting, ); if (!hasHomeLineup || !hasAwayLineup) { const sidelined = match.sidelined && typeof match.sidelined === "object" ? (match.sidelined as Record) : {}; const matchDateMs = Number(match.mstUtc || Date.now()); const probableLineups: any[] = []; if (!hasHomeLineup && match.homeTeamId) { probableLineups.push( ...(await this.buildProbableLineupForTeam({ teamId: match.homeTeamId, beforeDateMs: matchDateMs, sidelinedTeamData: sidelined.homeTeam, })), ); } if (!hasAwayLineup && match.awayTeamId) { probableLineups.push( ...(await this.buildProbableLineupForTeam({ teamId: match.awayTeamId, beforeDateMs: matchDateMs, sidelinedTeamData: sidelined.awayTeam, })), ); } if (probableLineups.length > 0) { match.playerParticipations = canTrustStoredLineups ? [...match.playerParticipations, ...probableLineups] : probableLineups; match.lineupSource = "probable_xi"; } } } // Structure odds const odds: Record< string, Record > = {}; if ( match.isLiveSource && match.odds && typeof match.odds === "object" && !Array.isArray(match.odds) ) { // Parse JSON odds from LiveMatch const oddsObj = match.odds as Record>; for (const [marketName, selections] of Object.entries(oddsObj)) { odds[marketName] = {}; if (selections && typeof selections === "object") { for (const [selName, selOdd] of Object.entries(selections)) { odds[marketName][selName] = { odd: String(selOdd) }; } } } } else if (match.oddCategories) { // Parse relation odds from Match for (const cat of match.oddCategories) { if (!cat.name) continue; odds[cat.name] = {}; for (const sel of cat.selections) { if (sel.name) { odds[cat.name][sel.name] = { odd: sel.oddValue || "", sov: sel.sov ?? undefined, }; } } } } const sportStats = match.sport === "basketball" ? match.basketballTeamStats || [] : match.footballTeamStats || []; const normalizedTeamStats = sportStats.map((s: any) => this.normalizeTeamStat(s, match.sport), ); const homeStat = sportStats.find((s: any) => s.teamId === match.homeTeamId); const awayStat = sportStats.find((s: any) => s.teamId === match.awayTeamId); return { ...match, teamStats: normalizedTeamStats, mstUtc: Number(match.mstUtc), date: match.date || new Date(Number(match.mstUtc)), // Ensure score is in expected format (nested object for frontend if needed, but frontend seems to use match.score.home in some places and match.scoreHome in others. // The match-detail-content uses match.score.home. Match entity has scoreHome/scoreAway fields. // Let's ensure compatibility. score: match.score || { home: match.scoreHome, away: match.scoreAway }, stats: { home: this.normalizeTeamStat(homeStat, match.sport), away: this.normalizeTeamStat(awayStat, match.sport), }, lineups: { home: match.playerParticipations.filter( (p: any) => p.teamId === match.homeTeamId, ), away: match.playerParticipations.filter( (p: any) => p.teamId === match.awayTeamId, ), }, events: match.playerEvents, odds, }; } /** * Get team ID by name (for legacy compatibility) */ async getTeamIdByName( teamName: string, sport: Sport, ): Promise { const trimmedName = teamName.trim(); // Exact match first let team = await this.prisma.team.findFirst({ where: { name: trimmedName, sport: sport as any }, select: { id: true }, }); if (team) return team.id; // Fuzzy search team = await this.prisma.team.findFirst({ where: { name: { contains: trimmedName, mode: "insensitive" }, sport: sport as any, }, select: { id: true }, }); return team?.id || null; } private async buildProbableLineupForTeam(params: { teamId: string; beforeDateMs: number; sidelinedTeamData?: any; matchLimit?: number; lookbackDays?: number; maxStalenessDays?: number; }) { const matchLimit = params.matchLimit ?? 5; const lookbackDays = params.lookbackDays ?? 370; const maxStalenessDays = params.maxStalenessDays ?? 120; const beforeDateMs = params.beforeDateMs || Date.now(); const minDateMs = Math.max( 0, beforeDateMs - lookbackDays * 24 * 60 * 60 * 1000, ); const excluded = this.extractSidelinedPlayerIds(params.sidelinedTeamData); const rows = await this.prisma.$queryRaw` SELECT mpp.player_id AS "playerId", p.name AS "playerName", mpp.position AS "position", mpp.shirt_number AS "shirtNumber", m.id AS "matchId", m.mst_utc AS "mstUtc" FROM match_player_participation mpp JOIN matches m ON m.id = mpp.match_id JOIN players p ON p.id = mpp.player_id WHERE mpp.team_id = ${params.teamId} AND mpp.is_starting = true AND NOT EXISTS ( SELECT 1 FROM match_player_participation later_mpp JOIN matches later_m ON later_m.id = later_mpp.match_id WHERE later_mpp.player_id = mpp.player_id AND later_mpp.team_id <> ${params.teamId} AND later_m.mst_utc > m.mst_utc AND later_m.mst_utc < ${BigInt(beforeDateMs)} AND ( later_m.status = 'FT' OR later_m.state = 'postGame' OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL) ) ) AND m.id IN ( SELECT m2.id FROM matches m2 JOIN match_player_participation recent_mpp ON recent_mpp.match_id = m2.id AND recent_mpp.team_id = ${params.teamId} AND recent_mpp.is_starting = true WHERE (m2.home_team_id = ${params.teamId} OR m2.away_team_id = ${params.teamId}) AND ( m2.status = 'FT' OR m2.state = 'postGame' OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL) ) AND m2.mst_utc < ${BigInt(beforeDateMs)} AND m2.mst_utc >= ${BigInt(minDateMs)} GROUP BY m2.id HAVING COUNT(recent_mpp.*) >= 9 ORDER BY MAX(m2.mst_utc) DESC LIMIT ${matchLimit} ) ORDER BY m.mst_utc DESC `; if (!rows.length) return []; const latestMst = Math.max( ...rows.map((row) => Number(row.mstUtc || 0)), ); const ageDays = latestMst > 0 ? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000) : Number.POSITIVE_INFINITY; const staleProjection = ageDays > maxStalenessDays; const matchOrder = new Map(); for (const row of rows) { const matchId = String(row.matchId); if (!matchOrder.has(matchId)) { matchOrder.set(matchId, matchOrder.size); } } const playerMap = new Map< string, { playerId: string; playerName: string; position: string | null; shirtNumber: number | null; score: number; starts: number; lastSeenRank: number; } >(); for (const row of rows) { const playerId = String(row.playerId); if (excluded.has(playerId)) continue; const rank = matchOrder.get(String(row.matchId)) ?? matchLimit; const recencyWeight = Math.max(1, matchLimit - rank); const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0); const existing = playerMap.get(playerId); if (!existing) { playerMap.set(playerId, { playerId, playerName: row.playerName || "Bilinmiyor", position: row.position ?? null, shirtNumber: row.shirtNumber === null || row.shirtNumber === undefined ? null : Number(row.shirtNumber), score, starts: 1, lastSeenRank: rank, }); } else { existing.score += score; existing.starts += 1; existing.lastSeenRank = Math.min(existing.lastSeenRank, rank); existing.position = existing.position || row.position || null; existing.shirtNumber = existing.shirtNumber ?? (row.shirtNumber === null || row.shirtNumber === undefined ? null : Number(row.shirtNumber)); } } const ranked = [...playerMap.values()] .sort((a, b) => { if (b.score !== a.score) return b.score - a.score; if (b.starts !== a.starts) return b.starts - a.starts; return a.lastSeenRank - b.lastSeenRank; }) .slice(0, 11); const coverage = Math.min(1, ranked.length / 11); const historyScore = Math.min(1, matchOrder.size / matchLimit); const stableCore = ranked.filter((p) => p.starts >= 2).length / 11; const stalenessFactor = Math.max( 0.35, Math.min(1, maxStalenessDays / Math.max(ageDays, 1)), ); const confidence = Math.max( 0, Math.min( staleProjection ? 0.58 : 0.88, (coverage * 0.45 + historyScore * 0.25 + stableCore * 0.3) * stalenessFactor, ), ); return ranked.map((p) => ({ teamId: params.teamId, isStarting: true, shirtNumber: p.shirtNumber, position: p.position, isProbable: true, lineupSource: "probable_xi", projectionConfidence: Number(confidence.toFixed(3)), projectionAgeDays: Number(ageDays.toFixed(1)), projectionStale: staleProjection, projectionMatchLimit: matchLimit, projectionLookbackDays: lookbackDays, projectionMaxStalenessDays: maxStalenessDays, player: { id: p.playerId, name: p.playerName, }, })); } private extractSidelinedPlayerIds(teamData: any): Set { if (!teamData || typeof teamData !== "object") return new Set(); const players = Array.isArray(teamData.players) ? teamData.players : []; return new Set( players .map((player: any) => String( player?.playerId ?? player?.player_id ?? player?.id ?? player?.personId ?? "", ), ) .filter(Boolean), ); } private canTrustStoredLineups(displayStatus?: string): boolean { const normalized = String(displayStatus || "").toLowerCase(); return ( normalized === "live" || normalized === "finished" || normalized === "ft" ); } }