Files
iddaai-be/src/modules/matches/matches.service.ts
T
fahricansecer 9027cc9900
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s
v28
2026-04-24 23:46:28 +03:00

1005 lines
30 KiB
TypeScript
Executable File

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<string[]> {
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<string[]> {
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<string[]> {
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<LeagueWithMatchesDto[]> {
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<string, LeagueWithMatchesDto>();
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<string, Record<string, number>>;
for (const [marketName, selections] of Object.entries(oddsObj)) {
const structuredSelections: Record<string, { odd: string }> = {};
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<ActiveLeagueDto[]> {
// Use raw query for complex aggregation
const leagues = await this.prisma.$queryRaw<any[]>`
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<string, any>;
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<string, any>)
: {};
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<string, { odd: string; sov?: number }>
> = {};
if (
match.isLiveSource &&
match.odds &&
typeof match.odds === "object" &&
!Array.isArray(match.odds)
) {
// Parse JSON odds from LiveMatch
const oddsObj = match.odds as Record<string, Record<string, number>>;
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<string | null> {
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<any[]>`
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<string, number>();
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<string> {
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"
);
}
}