From 7c2bb121888881efcff810958ef1b2dcd69d63a2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 19:06:17 -0500 Subject: [PATCH 001/103] Add ComparisonGroup model to Prisma schema and migration --- .../migration.sql | 30 +++++++ prisma/schema.prisma | 85 ++++++++++++------- 2 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260128000532_add_comparison_group_model/migration.sql diff --git a/prisma/migrations/20260128000532_add_comparison_group_model/migration.sql b/prisma/migrations/20260128000532_add_comparison_group_model/migration.sql new file mode 100644 index 000000000..d04eb4099 --- /dev/null +++ b/prisma/migrations/20260128000532_add_comparison_group_model/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "public"."ComparisonGroup" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "teamId" INTEGER NOT NULL, + "createdBy" TEXT NOT NULL, + "playerName" TEXT NOT NULL, + "heroes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "mapIds" INTEGER[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ComparisonGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ComparisonGroup_teamId_idx" ON "public"."ComparisonGroup"("teamId"); + +-- CreateIndex +CREATE INDEX "ComparisonGroup_createdBy_idx" ON "public"."ComparisonGroup"("createdBy"); + +-- CreateIndex +CREATE INDEX "ComparisonGroup_teamId_playerName_idx" ON "public"."ComparisonGroup"("teamId", "playerName"); + +-- AddForeignKey +ALTER TABLE "public"."ComparisonGroup" ADD CONSTRAINT "ComparisonGroup_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ComparisonGroup" ADD CONSTRAINT "ComparisonGroup_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1473f7d4..c124d77c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,28 +42,29 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - role UserRole @default(USER) - teamId Int? - teams Team[] - managedTeams TeamManager[] - stripeId String? @unique - billingPlan BillingPlan @default(FREE) - Notification Notification[] - AppSettings AppSettings[] - titles Title[] @default([]) - bannerImage String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - battletag String? - appliedTitles AppliedTitle[] - seenOnboarding Boolean @default(false) + id String @id @default(cuid()) + name String? + email String @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + role UserRole @default(USER) + teamId Int? + teams Team[] + managedTeams TeamManager[] + stripeId String? @unique + billingPlan BillingPlan @default(FREE) + Notification Notification[] + AppSettings AppSettings[] + titles Title[] @default([]) + bannerImage String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + battletag String? + appliedTitles AppliedTitle[] + seenOnboarding Boolean @default(false) + comparisonGroups ComparisonGroup[] @@index([id, email]) } @@ -183,16 +184,17 @@ model AuditLog { } model Team { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - image String? - users User[] - scrims Scrim[] - ownerId String - managers TeamManager[] - readonly Boolean @default(false) + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + image String? + users User[] + scrims Scrim[] + ownerId String + managers TeamManager[] + readonly Boolean @default(false) + comparisonGroups ComparisonGroup[] } model TeamManager { @@ -220,6 +222,25 @@ model TeamInviteToken { expires DateTime } +model ComparisonGroup { + id Int @id @default(autoincrement()) + name String + description String? + teamId Int + createdBy String + playerName String + heroes String[] @default([]) + mapIds Int[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@index([teamId]) + @@index([createdBy]) + @@index([teamId, playerName]) +} + model Scrim { id Int @id @default(autoincrement()) name String From 4f04d1e5f7e5d568861467f9cd5dd87e4761b9dd Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:03:15 -0500 Subject: [PATCH 002/103] Add ComparisonStats functionality with detailed player performance metrics and trends analysis --- src/data/comparison-dto.ts | 795 +++++++++++++++++++++++++++++++++++++ 1 file changed, 795 insertions(+) create mode 100644 src/data/comparison-dto.ts diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts new file mode 100644 index 000000000..69f84a300 --- /dev/null +++ b/src/data/comparison-dto.ts @@ -0,0 +1,795 @@ +import "server-only"; + +import prisma from "@/lib/prisma"; +import { removeDuplicateRows } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { + type CalculatedStat, + CalculatedStatType, + type MapType, + type PlayerStat, + Prisma, +} from "@prisma/client"; +import { cache } from "react"; + +export type AggregatedStats = { + eliminations: number; + finalBlows: number; + deaths: number; + allDamageDealt: number; + barrierDamageDealt: number; + heroDamageDealt: number; + healingDealt: number; + healingReceived: number; + selfHealing: number; + damageTaken: number; + damageBlocked: number; + defensiveAssists: number; + offensiveAssists: number; + ultimatesEarned: number; + ultimatesUsed: number; + multikillBest: number; + multikills: number; + soloKills: number; + objectiveKills: number; + environmentalKills: number; + environmentalDeaths: number; + criticalHits: number; + shotsFired: number; + shotsHit: number; + shotsMissed: number; + scopedShots: number; + scopedShotsHit: number; + scopedCriticalHitKills: number; + heroTimePlayed: number; + eliminationsPer10: number; + finalBlowsPer10: number; + deathsPer10: number; + allDamagePer10: number; + heroDamagePer10: number; + healingDealtPer10: number; + healingReceivedPer10: number; + damageTakenPer10: number; + damageBlockedPer10: number; + ultimatesEarnedPer10: number; + weaponAccuracy: number; + criticalHitAccuracy: number; + scopedAccuracy: number; + scopedCriticalHitAccuracy: number; + fletaDeadliftPercentage: number; + firstPickPercentage: number; + firstPickCount: number; + firstDeathPercentage: number; + firstDeathCount: number; + mvpScore: number; + mapMvpCount: number; + ajaxCount: number; + averageUltChargeTime: number; + averageTimeToUseUlt: number; + averageDroughtTime: number; + killsPerUltimate: number; + duelWinratePercentage: number; + fightReversalPercentage: number; +}; + +export type MapBreakdown = { + mapId: number; + mapDataId: number; + mapName: string; + mapType: MapType; + scrimId: number; + scrimName: string; + date: Date; + replayCode: string | null; + heroes: HeroName[]; + stats: PlayerStat; + calculatedStats: CalculatedStat[]; +}; + +export type TrendsAnalysis = { + improvingMetrics: { + metric: string; + change: number; + changePercentage: number; + }[]; + decliningMetrics: { + metric: string; + change: number; + changePercentage: number; + }[]; + earlyPerformance?: AggregatedStats; + latePerformance?: AggregatedStats; +}; + +export type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: AggregatedStats; + perMapBreakdown: MapBreakdown[]; + trends?: TrendsAnalysis; + heroBreakdown?: Record; +}; + +function calculatePer10(value: number, timePlayed: number): number { + if (timePlayed === 0) return 0; + return (value / timePlayed) * 600; +} + +function calculatePercentage(value: number, total: number): number { + if (total === 0) return 0; + return (value / total) * 100; +} + +function aggregateCalculatedStats( + stats: CalculatedStat[] +): Partial { + const result: Partial = { + fletaDeadliftPercentage: 0, + firstPickPercentage: 0, + firstPickCount: 0, + firstDeathPercentage: 0, + firstDeathCount: 0, + mvpScore: 0, + mapMvpCount: 0, + ajaxCount: 0, + averageUltChargeTime: 0, + averageTimeToUseUlt: 0, + averageDroughtTime: 0, + killsPerUltimate: 0, + duelWinratePercentage: 0, + fightReversalPercentage: 0, + }; + + const counts: Record = {}; + + stats.forEach((stat) => { + switch (stat.stat) { + case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) + stat.value; + counts.fletaDeadlift = (counts.fletaDeadlift ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_PERCENTAGE: + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) + stat.value; + counts.firstPick = (counts.firstPick ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_COUNT: + result.firstPickCount = (result.firstPickCount ?? 0) + stat.value; + break; + case CalculatedStatType.FIRST_DEATH_PERCENTAGE: + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) + stat.value; + counts.firstDeath = (counts.firstDeath ?? 0) + 1; + break; + case CalculatedStatType.FIRST_DEATH_COUNT: + result.firstDeathCount = (result.firstDeathCount ?? 0) + stat.value; + break; + case CalculatedStatType.MVP_SCORE: + result.mvpScore = (result.mvpScore ?? 0) + stat.value; + counts.mvpScore = (counts.mvpScore ?? 0) + 1; + break; + case CalculatedStatType.MAP_MVP_COUNT: + result.mapMvpCount = (result.mapMvpCount ?? 0) + stat.value; + break; + case CalculatedStatType.AJAX_COUNT: + result.ajaxCount = (result.ajaxCount ?? 0) + stat.value; + break; + case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) + stat.value; + counts.ultCharge = (counts.ultCharge ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_TIME_TO_USE_ULT: + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) + stat.value; + counts.timeToUseUlt = (counts.timeToUseUlt ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_DROUGHT_TIME: + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) + stat.value; + counts.drought = (counts.drought ?? 0) + 1; + break; + case CalculatedStatType.KILLS_PER_ULTIMATE: + result.killsPerUltimate = (result.killsPerUltimate ?? 0) + stat.value; + counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; + break; + case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) + stat.value; + counts.duelWinrate = (counts.duelWinrate ?? 0) + 1; + break; + case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) + stat.value; + counts.fightReversal = (counts.fightReversal ?? 0) + 1; + break; + } + }); + + if (counts.fletaDeadlift) { + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) / counts.fletaDeadlift; + } + if (counts.firstPick) { + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) / counts.firstPick; + } + if (counts.firstDeath) { + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) / counts.firstDeath; + } + if (counts.mvpScore) { + result.mvpScore = (result.mvpScore ?? 0) / counts.mvpScore; + } + if (counts.ultCharge) { + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) / counts.ultCharge; + } + if (counts.timeToUseUlt) { + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) / counts.timeToUseUlt; + } + if (counts.drought) { + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) / counts.drought; + } + if (counts.killsPerUlt) { + result.killsPerUltimate = + (result.killsPerUltimate ?? 0) / counts.killsPerUlt; + } + if (counts.duelWinrate) { + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) / counts.duelWinrate; + } + if (counts.fightReversal) { + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) / counts.fightReversal; + } + + return result; +} + +function aggregatePlayerStats( + stats: PlayerStat[], + calculatedStats: CalculatedStat[] +): AggregatedStats { + const totals = stats.reduce( + (acc, stat) => { + acc.eliminations += stat.eliminations; + acc.finalBlows += stat.final_blows; + acc.deaths += stat.deaths; + acc.allDamageDealt += stat.all_damage_dealt; + acc.barrierDamageDealt += stat.barrier_damage_dealt; + acc.heroDamageDealt += stat.hero_damage_dealt; + acc.healingDealt += stat.healing_dealt; + acc.healingReceived += stat.healing_received; + acc.selfHealing += stat.self_healing; + acc.damageTaken += stat.damage_taken; + acc.damageBlocked += stat.damage_blocked; + acc.defensiveAssists += stat.defensive_assists; + acc.offensiveAssists += stat.offensive_assists; + acc.ultimatesEarned += stat.ultimates_earned; + acc.ultimatesUsed += stat.ultimates_used; + acc.multikillBest = Math.max(acc.multikillBest, stat.multikill_best); + acc.multikills += stat.multikills; + acc.soloKills += stat.solo_kills; + acc.objectiveKills += stat.objective_kills; + acc.environmentalKills += stat.environmental_kills; + acc.environmentalDeaths += stat.environmental_deaths; + acc.criticalHits += stat.critical_hits; + acc.shotsFired += stat.shots_fired; + acc.shotsHit += stat.shots_hit; + acc.shotsMissed += stat.shots_missed; + acc.scopedShots += stat.scoped_shots; + acc.scopedShotsHit += stat.scoped_shots_hit; + acc.scopedCriticalHitKills += stat.scoped_critical_hit_kills; + acc.heroTimePlayed += stat.hero_time_played; + return acc; + }, + { + eliminations: 0, + finalBlows: 0, + deaths: 0, + allDamageDealt: 0, + barrierDamageDealt: 0, + heroDamageDealt: 0, + healingDealt: 0, + healingReceived: 0, + selfHealing: 0, + damageTaken: 0, + damageBlocked: 0, + defensiveAssists: 0, + offensiveAssists: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + multikillBest: 0, + multikills: 0, + soloKills: 0, + objectiveKills: 0, + environmentalKills: 0, + environmentalDeaths: 0, + criticalHits: 0, + shotsFired: 0, + shotsHit: 0, + shotsMissed: 0, + scopedShots: 0, + scopedShotsHit: 0, + scopedCriticalHitKills: 0, + heroTimePlayed: 0, + } + ); + + const calculatedAggregates = aggregateCalculatedStats(calculatedStats); + + return { + ...totals, + eliminationsPer10: calculatePer10( + totals.eliminations, + totals.heroTimePlayed + ), + finalBlowsPer10: calculatePer10(totals.finalBlows, totals.heroTimePlayed), + deathsPer10: calculatePer10(totals.deaths, totals.heroTimePlayed), + allDamagePer10: calculatePer10( + totals.allDamageDealt, + totals.heroTimePlayed + ), + heroDamagePer10: calculatePer10( + totals.heroDamageDealt, + totals.heroTimePlayed + ), + healingDealtPer10: calculatePer10( + totals.healingDealt, + totals.heroTimePlayed + ), + healingReceivedPer10: calculatePer10( + totals.healingReceived, + totals.heroTimePlayed + ), + damageTakenPer10: calculatePer10(totals.damageTaken, totals.heroTimePlayed), + damageBlockedPer10: calculatePer10( + totals.damageBlocked, + totals.heroTimePlayed + ), + ultimatesEarnedPer10: calculatePer10( + totals.ultimatesEarned, + totals.heroTimePlayed + ), + weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired), + criticalHitAccuracy: calculatePercentage( + totals.criticalHits, + totals.shotsHit + ), + scopedAccuracy: calculatePercentage( + totals.scopedShotsHit, + totals.scopedShots + ), + scopedCriticalHitAccuracy: calculatePercentage( + totals.scopedCriticalHitKills, + totals.scopedShotsHit + ), + fletaDeadliftPercentage: calculatedAggregates.fletaDeadliftPercentage ?? 0, + firstPickPercentage: calculatedAggregates.firstPickPercentage ?? 0, + firstPickCount: calculatedAggregates.firstPickCount ?? 0, + firstDeathPercentage: calculatedAggregates.firstDeathPercentage ?? 0, + firstDeathCount: calculatedAggregates.firstDeathCount ?? 0, + mvpScore: calculatedAggregates.mvpScore ?? 0, + mapMvpCount: calculatedAggregates.mapMvpCount ?? 0, + ajaxCount: calculatedAggregates.ajaxCount ?? 0, + averageUltChargeTime: calculatedAggregates.averageUltChargeTime ?? 0, + averageTimeToUseUlt: calculatedAggregates.averageTimeToUseUlt ?? 0, + averageDroughtTime: calculatedAggregates.averageDroughtTime ?? 0, + killsPerUltimate: calculatedAggregates.killsPerUltimate ?? 0, + duelWinratePercentage: calculatedAggregates.duelWinratePercentage ?? 0, + fightReversalPercentage: calculatedAggregates.fightReversalPercentage ?? 0, + }; +} + +function calculateTrends( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] +): TrendsAnalysis { + if (perMapStats.length < 3) { + return { + improvingMetrics: [], + decliningMetrics: [], + }; + } + + const midpoint = Math.floor(perMapStats.length / 2); + const firstHalfStats = perMapStats.slice(0, midpoint); + const secondHalfStats = perMapStats.slice(midpoint); + + const firstHalfCalculated = perMapCalculatedStats.slice(0, midpoint).flat(); + const secondHalfCalculated = perMapCalculatedStats.slice(midpoint).flat(); + + const earlyPerformance = aggregatePlayerStats( + firstHalfStats, + firstHalfCalculated + ); + const latePerformance = aggregatePlayerStats( + secondHalfStats, + secondHalfCalculated + ); + + const metricComparisons = [ + { + name: "Eliminations per 10", + early: earlyPerformance.eliminationsPer10, + late: latePerformance.eliminationsPer10, + }, + { + name: "Deaths per 10", + early: earlyPerformance.deathsPer10, + late: latePerformance.deathsPer10, + invertImprovement: true, + }, + { + name: "Damage Dealt per 10", + early: earlyPerformance.heroDamagePer10, + late: latePerformance.heroDamagePer10, + }, + { + name: "Damage Taken per 10", + early: earlyPerformance.damageTakenPer10, + late: latePerformance.damageTakenPer10, + invertImprovement: true, + }, + { + name: "First Death %", + early: earlyPerformance.firstDeathPercentage, + late: latePerformance.firstDeathPercentage, + invertImprovement: true, + }, + { + name: "MVP Score", + early: earlyPerformance.mvpScore, + late: latePerformance.mvpScore, + }, + ]; + + const improvingMetrics: { + metric: string; + change: number; + changePercentage: number; + }[] = []; + const decliningMetrics: { + metric: string; + change: number; + changePercentage: number; + }[] = []; + + metricComparisons.forEach((comparison) => { + const change = comparison.late - comparison.early; + const changePercentage = + comparison.early !== 0 + ? ((comparison.late - comparison.early) / comparison.early) * 100 + : 0; + + const isImprovement = comparison.invertImprovement + ? change < 0 + : change > 0; + + if (Math.abs(changePercentage) > 5) { + if (isImprovement) { + improvingMetrics.push({ + metric: comparison.name, + change, + changePercentage, + }); + } else { + decliningMetrics.push({ + metric: comparison.name, + change, + changePercentage, + }); + } + } + }); + + return { + improvingMetrics, + decliningMetrics, + earlyPerformance: perMapStats.length >= 4 ? earlyPerformance : undefined, + latePerformance: perMapStats.length >= 4 ? latePerformance : undefined, + }; +} + +async function getComparisonStatsFn( + mapIds: number[], + playerName: string, + heroes?: HeroName[] +): Promise { + if (mapIds.length === 0) { + throw new Error("At least one map must be provided"); + } + + const maps = await prisma.map.findMany({ + where: { id: { in: mapIds } }, + include: { + Scrim: true, + mapData: { + include: { + match_start: true, + }, + }, + }, + }); + + const mapDataIds = maps.flatMap((map) => map.mapData.map((md) => md.id)); + + if (mapDataIds.length === 0) { + throw new Error("No map data found for the provided map IDs"); + } + + const finalRoundStats = removeDuplicateRows( + await prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapDataIds)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapDataIds)}) + AND ps."player_name" ILIKE ${playerName} + ${heroes && heroes.length > 0 ? Prisma.sql`AND ps."player_hero" IN (${Prisma.join(heroes)})` : Prisma.empty} + ` + ); + + const calculatedStatsWhere: Prisma.CalculatedStatWhereInput = { + MapDataId: { in: mapDataIds }, + playerName: { equals: playerName, mode: "insensitive" }, + ...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}), + }; + + const calculatedStats = await prisma.calculatedStat.findMany({ + where: calculatedStatsWhere, + }); + + const calculatedStatsByMapDataId: Record = {}; + calculatedStats.forEach((stat) => { + if (!calculatedStatsByMapDataId[stat.MapDataId]) { + calculatedStatsByMapDataId[stat.MapDataId] = []; + } + calculatedStatsByMapDataId[stat.MapDataId].push(stat); + }); + + const statsByMapDataId: Record = {}; + finalRoundStats.forEach((stat) => { + if (!statsByMapDataId[stat.MapDataId!]) { + statsByMapDataId[stat.MapDataId!] = []; + } + statsByMapDataId[stat.MapDataId!].push(stat); + }); + + const perMapBreakdown: MapBreakdown[] = []; + for (const map of maps) { + for (const mapData of map.mapData) { + const mapStats = statsByMapDataId[mapData.id] || []; + const mapCalcStats = calculatedStatsByMapDataId[mapData.id] || []; + + if (mapStats.length === 0) continue; + + const matchStart = mapData.match_start[0]; + const heroesPlayed = Array.from( + new Set(mapStats.map((s) => s.player_hero)) + ); + + perMapBreakdown.push({ + mapId: map.id, + mapDataId: mapData.id, + mapName: matchStart?.map_name || map.name, + mapType: matchStart?.map_type || ("Control" as MapType), + scrimId: map.scrimId ?? 0, + scrimName: map.Scrim?.name ?? "Unknown", + date: map.Scrim?.date ?? map.createdAt, + replayCode: map.replayCode, + heroes: heroesPlayed as HeroName[], + stats: mapStats[0], + calculatedStats: mapCalcStats, + }); + } + } + + perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); + + const aggregated = aggregatePlayerStats(finalRoundStats, calculatedStats); + + const trends = + perMapBreakdown.length >= 3 + ? calculateTrends( + perMapBreakdown.map((m) => m.stats), + perMapBreakdown.map((m) => m.calculatedStats) + ) + : undefined; + + let heroBreakdown: Record | undefined; + if (!heroes || heroes.length > 1) { + const heroesInvolved = Array.from( + new Set(finalRoundStats.map((s) => s.player_hero)) + ); + if (heroesInvolved.length > 1) { + heroBreakdown = {}; + for (const hero of heroesInvolved) { + const heroStats = finalRoundStats.filter((s) => s.player_hero === hero); + const heroCalcStats = calculatedStats.filter((s) => s.hero === hero); + heroBreakdown[hero] = aggregatePlayerStats(heroStats, heroCalcStats); + } + } + } + + return { + playerName, + filteredHeroes: heroes ?? [], + mapCount: perMapBreakdown.length, + mapIds, + aggregated, + perMapBreakdown, + trends, + heroBreakdown, + }; +} + +export const getComparisonStats = cache(getComparisonStatsFn); + +async function getAvailableMapsForComparisonFn(params: { + teamId: number; + playerName: string; + dateFrom?: Date; + dateTo?: Date; + mapType?: MapType; + heroes?: HeroName[]; +}): Promise< + { + id: number; + name: string; + scrimId: number; + scrimName: string; + date: Date; + mapType: MapType; + replayCode: string | null; + playerHeroes: HeroName[]; + }[] +> { + const { teamId, playerName, dateFrom, dateTo, mapType, heroes } = params; + + const dateFilter = + dateFrom || dateTo + ? { + date: { + ...(dateFrom ? { gte: dateFrom } : {}), + ...(dateTo ? { lte: dateTo } : {}), + }, + } + : {}; + + const scrims = await prisma.scrim.findMany({ + where: { + teamId, + ...dateFilter, + }, + include: { + maps: { + include: { + mapData: { + include: { + match_start: true, + player_stat: { + where: { + player_name: { equals: playerName, mode: "insensitive" }, + ...(heroes && heroes.length > 0 + ? { player_hero: { in: heroes } } + : {}), + }, + }, + }, + }, + }, + }, + }, + }); + + const availableMaps: { + id: number; + name: string; + scrimId: number; + scrimName: string; + date: Date; + mapType: MapType; + replayCode: string | null; + playerHeroes: HeroName[]; + }[] = []; + + for (const scrim of scrims) { + for (const map of scrim.maps) { + const playerStats = map.mapData.flatMap((md) => md.player_stat); + if (playerStats.length === 0) continue; + + const matchStart = map.mapData[0]?.match_start[0]; + if (!matchStart) continue; + + if (mapType && matchStart.map_type !== mapType) continue; + + const heroesPlayed = Array.from( + new Set(playerStats.map((s) => s.player_hero)) + ) as HeroName[]; + + availableMaps.push({ + id: map.id, + name: matchStart.map_name, + scrimId: scrim.id, + scrimName: scrim.name, + date: scrim.date, + mapType: matchStart.map_type, + replayCode: map.replayCode, + playerHeroes: heroesPlayed, + }); + } + } + + availableMaps.sort((a, b) => b.date.getTime() - a.date.getTime()); + + return availableMaps; +} + +export const getAvailableMapsForComparison = cache( + getAvailableMapsForComparisonFn +); + +async function getTeamPlayersFn( + teamId: number +): Promise<{ name: string; mapCount: number }[]> { + const scrims = await prisma.scrim.findMany({ + where: { teamId }, + select: { + maps: { + select: { + mapData: { + select: { + player_stat: { + select: { + player_name: true, + }, + distinct: ["player_name"], + }, + }, + }, + }, + }, + }, + }); + + const playerMapCounts = new Map(); + + for (const scrim of scrims) { + for (const map of scrim.maps) { + for (const mapData of map.mapData) { + for (const stat of mapData.player_stat) { + const currentCount = playerMapCounts.get(stat.player_name) ?? 0; + playerMapCounts.set(stat.player_name, currentCount + 1); + } + } + } + } + + const players = Array.from(playerMapCounts.entries()) + .map(([name, mapCount]) => ({ name, mapCount })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return players; +} + +export const getTeamPlayers = cache(getTeamPlayersFn); From 11cc38e320115227d1c91c0458d4acc6ebd193ec Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:10:48 -0500 Subject: [PATCH 003/103] Add API routes for comparison groups, maps, players, and stats with authentication and validation --- src/app/api/compare/groups/[id]/route.ts | 148 +++++++++++++++ src/app/api/compare/groups/[teamId]/route.ts | 117 ++++++++++++ src/app/api/compare/groups/route.ts | 179 +++++++++++++++++++ src/app/api/compare/maps/route.ts | 128 +++++++++++++ src/app/api/compare/players/route.ts | 84 +++++++++ src/app/api/compare/stats/route.ts | 158 ++++++++++++++++ 6 files changed, 814 insertions(+) create mode 100644 src/app/api/compare/groups/[id]/route.ts create mode 100644 src/app/api/compare/groups/[teamId]/route.ts create mode 100644 src/app/api/compare/groups/route.ts create mode 100644 src/app/api/compare/maps/route.ts create mode 100644 src/app/api/compare/players/route.ts create mode 100644 src/app/api/compare/stats/route.ts diff --git a/src/app/api/compare/groups/[id]/route.ts b/src/app/api/compare/groups/[id]/route.ts new file mode 100644 index 000000000..913eef053 --- /dev/null +++ b/src/app/api/compare/groups/[id]/route.ts @@ -0,0 +1,148 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { $Enums } from "@prisma/client"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "DELETE", + path: "/api/compare/groups/[id]", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const { id: idParam } = await params; + const groupId = parseInt(idParam); + + if (isNaN(groupId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_group_id"; + wideEvent.error = { message: "Invalid group ID" }; + return new Response("Invalid group ID", { status: 400 }); + } + + wideEvent.group = { id: groupId }; + + const group = await prisma.comparisonGroup.findUnique({ + where: { id: groupId }, + include: { + team: { + select: { + ownerId: true, + users: { + where: { id: user.id }, + select: { id: true }, + }, + }, + }, + }, + }); + + if (!group) { + wideEvent.status_code = 404; + wideEvent.outcome = "group_not_found"; + wideEvent.error = { message: "Comparison group not found" }; + return NextResponse.json( + { + success: false, + error: "Comparison group not found", + }, + { status: 404 } + ); + } + + const isOwner = group.createdBy === user.id; + const isTeamOwner = group.team.ownerId === user.id; + const isAdmin = + user.role === $Enums.UserRole.ADMIN || + user.role === $Enums.UserRole.MANAGER; + const isTeamMember = group.team.users.length > 0; + + if (!isOwner && !isTeamOwner && !isAdmin) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { + message: "User does not have permission to delete this group", + }; + wideEvent.permissions = { + is_owner: isOwner, + is_team_owner: isTeamOwner, + is_admin: isAdmin, + is_team_member: isTeamMember, + }; + return NextResponse.json( + { + success: false, + error: + "You must be the group creator, team owner, or admin to delete this group", + }, + { status: 403 } + ); + } + + wideEvent.permissions = { + is_owner: isOwner, + is_team_owner: isTeamOwner, + is_admin: isAdmin, + }; + + await prisma.comparisonGroup.delete({ + where: { id: groupId }, + }); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + deleted_group_id: groupId, + group_name: group.name, + }; + + return NextResponse.json({ + success: true, + message: "Comparison group deleted successfully", + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error deleting comparison group", error); + return NextResponse.json( + { + success: false, + error: "Failed to delete comparison group", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/compare/groups/[teamId]/route.ts b/src/app/api/compare/groups/[teamId]/route.ts new file mode 100644 index 000000000..f1b23bc6f --- /dev/null +++ b/src/app/api/compare/groups/[teamId]/route.ts @@ -0,0 +1,117 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ teamId: string }> } +) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/compare/groups/[teamId]", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const { teamId: teamIdParam } = await params; + const playerNameParam = request.nextUrl.searchParams.get("playerName"); + + const teamId = parseInt(teamIdParam); + if (isNaN(teamId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team_id"; + wideEvent.error = { message: "Invalid team ID" }; + return new Response("Invalid team ID", { status: 400 }); + } + + wideEvent.team = { id: teamId }; + wideEvent.filters = { + player_name: playerNameParam, + }; + + const groups = await prisma.comparisonGroup.findMany({ + where: { + teamId, + ...(playerNameParam + ? { playerName: { equals: playerNameParam, mode: "insensitive" } } + : {}), + }, + include: { + creator: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const formattedGroups = groups.map((group) => ({ + id: group.id, + name: group.name, + description: group.description, + playerName: group.playerName, + heroes: group.heroes, + mapIds: group.mapIds, + mapCount: group.mapIds.length, + createdBy: group.creator.name ?? group.creator.email, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + })); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + group_count: formattedGroups.length, + filtered_by_player: !!playerNameParam, + }; + + return NextResponse.json({ + success: true, + groups: formattedGroups, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching comparison groups", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch comparison groups", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/compare/groups/route.ts b/src/app/api/compare/groups/route.ts new file mode 100644 index 000000000..32fcb1c2e --- /dev/null +++ b/src/app/api/compare/groups/route.ts @@ -0,0 +1,179 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import type { HeroName } from "@/types/heroes"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const CreateGroupSchema = z.object({ + name: z.string().min(1, "Name is required").max(100, "Name is too long"), + description: z.string().max(500, "Description is too long").optional(), + teamId: z.number().int().positive("Team ID must be positive"), + playerName: z.string().min(1, "Player name is required"), + mapIds: z.array(z.number()).min(1, "At least one map must be selected"), + heroes: z.array(z.string()).optional(), +}); + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/compare/groups", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const body = await request.json(); + const validatedData = CreateGroupSchema.safeParse(body); + + if (!validatedData.success) { + const firstError = validatedData.error.issues[0]; + wideEvent.status_code = 400; + wideEvent.outcome = "validation_error"; + wideEvent.error = { + message: firstError?.message ?? "Validation failed", + validation_errors: validatedData.error.issues.map((e) => ({ + path: e.path.join("."), + message: e.message, + })), + }; + return NextResponse.json( + { + success: false, + error: firstError?.message ?? "Validation failed", + details: validatedData.error.issues, + }, + { status: 400 } + ); + } + + const { name, description, teamId, playerName, mapIds, heroes } = + validatedData.data; + + const team = await prisma.team.findUnique({ + where: { id: teamId }, + include: { + users: { + where: { id: user.id }, + }, + }, + }); + + if (!team) { + wideEvent.status_code = 404; + wideEvent.outcome = "team_not_found"; + wideEvent.error = { message: "Team not found" }; + return NextResponse.json( + { + success: false, + error: "Team not found", + }, + { status: 404 } + ); + } + + if (team.users.length === 0 && team.ownerId !== user.id) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User is not a member of this team" }; + return NextResponse.json( + { + success: false, + error: "You must be a member of this team to save comparison groups", + }, + { status: 403 } + ); + } + + wideEvent.team = { id: teamId, name: team.name }; + wideEvent.group = { + name, + player_name: playerName, + map_count: mapIds.length, + hero_count: heroes?.length ?? 0, + }; + + const group = await prisma.comparisonGroup.create({ + data: { + name, + description, + teamId, + createdBy: user.id, + playerName, + mapIds, + heroes: (heroes as HeroName[]) ?? [], + }, + include: { + creator: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + wideEvent.status_code = 201; + wideEvent.outcome = "success"; + wideEvent.result = { + group_id: group.id, + }; + + return NextResponse.json( + { + success: true, + group: { + id: group.id, + name: group.name, + description: group.description, + playerName: group.playerName, + heroes: group.heroes, + mapIds: group.mapIds, + mapCount: group.mapIds.length, + createdBy: group.creator.name ?? group.creator.email, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + }, + }, + { status: 201 } + ); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error creating comparison group", error); + return NextResponse.json( + { + success: false, + error: "Failed to create comparison group", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/compare/maps/route.ts b/src/app/api/compare/maps/route.ts new file mode 100644 index 000000000..c29e513c6 --- /dev/null +++ b/src/app/api/compare/maps/route.ts @@ -0,0 +1,128 @@ +import { getAvailableMapsForComparison } from "@/data/comparison-dto"; +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import type { HeroName } from "@/types/heroes"; +import { MapType } from "@prisma/client"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/compare/maps", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const teamIdParam = request.nextUrl.searchParams.get("teamId"); + const playerName = request.nextUrl.searchParams.get("playerName"); + const dateFromParam = request.nextUrl.searchParams.get("dateFrom"); + const dateToParam = request.nextUrl.searchParams.get("dateTo"); + const mapTypeParam = request.nextUrl.searchParams.get("mapType"); + const heroesParam = request.nextUrl.searchParams.get("heroes"); + + if (!teamIdParam) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_team_id"; + wideEvent.error = { message: "Team ID is required" }; + return new Response("Team ID is required", { status: 400 }); + } + + if (!playerName) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_player_name"; + wideEvent.error = { message: "Player name is required" }; + return new Response("Player name is required", { status: 400 }); + } + + const teamId = parseInt(teamIdParam); + if (isNaN(teamId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team_id"; + wideEvent.error = { message: "Invalid team ID" }; + return new Response("Invalid team ID", { status: 400 }); + } + + const dateFrom = dateFromParam ? new Date(dateFromParam) : undefined; + const dateTo = dateToParam ? new Date(dateToParam) : undefined; + + const mapType = + mapTypeParam && Object.values(MapType).includes(mapTypeParam as MapType) + ? (mapTypeParam as MapType) + : undefined; + + const heroes = heroesParam + ? (heroesParam.split(",") as HeroName[]) + : undefined; + + wideEvent.team = { id: teamId }; + wideEvent.filters = { + player_name: playerName, + date_from: dateFrom?.toISOString(), + date_to: dateTo?.toISOString(), + map_type: mapType, + heroes, + hero_count: heroes?.length ?? 0, + }; + + const maps = await getAvailableMapsForComparison({ + teamId, + playerName, + dateFrom, + dateTo, + mapType, + heroes, + }); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + map_count: maps.length, + unique_scrims: new Set(maps.map((m) => m.scrimId)).size, + map_types: Array.from(new Set(maps.map((m) => m.mapType))), + }; + + return NextResponse.json({ + success: true, + maps, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching maps for comparison", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch maps", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/compare/players/route.ts b/src/app/api/compare/players/route.ts new file mode 100644 index 000000000..469c866c2 --- /dev/null +++ b/src/app/api/compare/players/route.ts @@ -0,0 +1,84 @@ +import { getTeamPlayers } from "@/data/comparison-dto"; +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/compare/players", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const teamIdParam = request.nextUrl.searchParams.get("teamId"); + if (!teamIdParam) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_team_id"; + wideEvent.error = { message: "Team ID is required" }; + return new Response("Team ID is required", { status: 400 }); + } + + const teamId = parseInt(teamIdParam); + if (isNaN(teamId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team_id"; + wideEvent.error = { message: "Invalid team ID" }; + return new Response("Invalid team ID", { status: 400 }); + } + + wideEvent.team = { id: teamId }; + + const players = await getTeamPlayers(teamId); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + player_count: players.length, + }; + + return NextResponse.json({ + success: true, + players, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching players for comparison", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch players", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/compare/stats/route.ts b/src/app/api/compare/stats/route.ts new file mode 100644 index 000000000..24c66b21a --- /dev/null +++ b/src/app/api/compare/stats/route.ts @@ -0,0 +1,158 @@ +import { getComparisonStats } from "@/data/comparison-dto"; +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import type { HeroName } from "@/types/heroes"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const MapIdsSchema = z + .array(z.number()) + .min(1, "At least one map must be provided"); +const PlayerNameSchema = z.string().min(1, "Player name is required"); +const HeroesSchema = z.array(z.string()).optional(); + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/compare/stats", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const mapIdsParam = request.nextUrl.searchParams.get("mapIds"); + const playerName = request.nextUrl.searchParams.get("playerName"); + const heroesParam = request.nextUrl.searchParams.get("heroes"); + + if (!mapIdsParam) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_map_ids"; + wideEvent.error = { message: "Map IDs are required" }; + return new Response("Map IDs are required", { status: 400 }); + } + + if (!playerName) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_player_name"; + wideEvent.error = { message: "Player name is required" }; + return new Response("Player name is required", { status: 400 }); + } + + let mapIds: number[]; + try { + mapIds = JSON.parse(mapIdsParam) as number[]; + } catch { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids"; + wideEvent.error = { message: "Map IDs must be a valid JSON array" }; + return new Response("Map IDs must be a valid JSON array", { + status: 400, + }); + } + + const validMapIds = MapIdsSchema.safeParse(mapIds); + if (!validMapIds.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids_validation"; + wideEvent.error = { + message: validMapIds.error.message ?? "Invalid map IDs", + }; + return new Response(validMapIds.error.message, { + status: 400, + }); + } + + const validPlayerName = PlayerNameSchema.safeParse(playerName); + if (!validPlayerName.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_player_name"; + wideEvent.error = { message: "Invalid player name" }; + return new Response("Invalid player name", { status: 400 }); + } + + let heroes: HeroName[] | undefined; + if (heroesParam) { + const heroesArray = heroesParam.split(","); + const validHeroes = HeroesSchema.safeParse(heroesArray); + if (!validHeroes.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_heroes"; + wideEvent.error = { message: "Invalid heroes parameter" }; + return new Response("Invalid heroes parameter", { status: 400 }); + } + heroes = heroesArray as HeroName[]; + } + + wideEvent.request_params = { + player_name: validPlayerName.data, + map_ids: validMapIds.data, + map_count: validMapIds.data.length, + heroes, + hero_count: heroes?.length ?? 0, + }; + + const comparisonStats = await getComparisonStats( + validMapIds.data, + validPlayerName.data, + heroes + ); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + map_count: comparisonStats.mapCount, + player_name: comparisonStats.playerName, + filtered_heroes: comparisonStats.filteredHeroes, + has_trends: !!comparisonStats.trends, + has_hero_breakdown: !!comparisonStats.heroBreakdown, + total_time_played_seconds: comparisonStats.aggregated.heroTimePlayed, + improving_metrics_count: + comparisonStats.trends?.improvingMetrics.length ?? 0, + declining_metrics_count: + comparisonStats.trends?.decliningMetrics.length ?? 0, + }; + + return NextResponse.json({ + success: true, + data: comparisonStats, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching comparison stats", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch comparison statistics", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} From 1b7a74d26177adc4ae37177ddaa88db46c70c83d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:21:15 -0500 Subject: [PATCH 004/103] Fix issue with route conflicts --- src/app/api/compare/groups/[teamId]/route.ts | 117 ------------------- src/app/api/compare/groups/route.ts | 115 ++++++++++++++++++ 2 files changed, 115 insertions(+), 117 deletions(-) delete mode 100644 src/app/api/compare/groups/[teamId]/route.ts diff --git a/src/app/api/compare/groups/[teamId]/route.ts b/src/app/api/compare/groups/[teamId]/route.ts deleted file mode 100644 index f1b23bc6f..000000000 --- a/src/app/api/compare/groups/[teamId]/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { getUser } from "@/data/user-dto"; -import { auth } from "@/lib/auth"; -import { Logger } from "@/lib/logger"; -import prisma from "@/lib/prisma"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - const startTime = Date.now(); - const wideEvent: Record = { - method: "GET", - path: "/api/compare/groups/[teamId]", - timestamp: new Date().toISOString(), - }; - - try { - const session = await auth(); - if (!session?.user?.email) { - wideEvent.status_code = 401; - wideEvent.outcome = "unauthorized"; - wideEvent.error = { message: "No session found" }; - return new Response("Unauthorized", { status: 401 }); - } - - const user = await getUser(session.user.email); - if (!user) { - wideEvent.status_code = 404; - wideEvent.outcome = "user_not_found"; - wideEvent.error = { message: "User not found" }; - return new Response("User not found", { status: 404 }); - } - - wideEvent.user = { id: user.id, email: user.email }; - - const { teamId: teamIdParam } = await params; - const playerNameParam = request.nextUrl.searchParams.get("playerName"); - - const teamId = parseInt(teamIdParam); - if (isNaN(teamId)) { - wideEvent.status_code = 400; - wideEvent.outcome = "invalid_team_id"; - wideEvent.error = { message: "Invalid team ID" }; - return new Response("Invalid team ID", { status: 400 }); - } - - wideEvent.team = { id: teamId }; - wideEvent.filters = { - player_name: playerNameParam, - }; - - const groups = await prisma.comparisonGroup.findMany({ - where: { - teamId, - ...(playerNameParam - ? { playerName: { equals: playerNameParam, mode: "insensitive" } } - : {}), - }, - include: { - creator: { - select: { - name: true, - email: true, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const formattedGroups = groups.map((group) => ({ - id: group.id, - name: group.name, - description: group.description, - playerName: group.playerName, - heroes: group.heroes, - mapIds: group.mapIds, - mapCount: group.mapIds.length, - createdBy: group.creator.name ?? group.creator.email, - createdAt: group.createdAt, - updatedAt: group.updatedAt, - })); - - wideEvent.status_code = 200; - wideEvent.outcome = "success"; - wideEvent.result = { - group_count: formattedGroups.length, - filtered_by_player: !!playerNameParam, - }; - - return NextResponse.json({ - success: true, - groups: formattedGroups, - }); - } catch (error) { - wideEvent.status_code = 500; - wideEvent.outcome = "error"; - wideEvent.error = { - message: error instanceof Error ? error.message : "Unknown error", - type: error instanceof Error ? error.name : "Error", - }; - Logger.error("Error fetching comparison groups", error); - return NextResponse.json( - { - success: false, - error: "Failed to fetch comparison groups", - }, - { status: 500 } - ); - } finally { - wideEvent.duration_ms = Date.now() - startTime; - Logger.info(wideEvent); - } -} diff --git a/src/app/api/compare/groups/route.ts b/src/app/api/compare/groups/route.ts index 32fcb1c2e..654c310c0 100644 --- a/src/app/api/compare/groups/route.ts +++ b/src/app/api/compare/groups/route.ts @@ -16,6 +16,121 @@ const CreateGroupSchema = z.object({ heroes: z.array(z.string()).optional(), }); +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/compare/groups", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + wideEvent.status_code = 401; + wideEvent.outcome = "unauthorized"; + wideEvent.error = { message: "No session found" }; + return new Response("Unauthorized", { status: 401 }); + } + + const user = await getUser(session.user.email); + if (!user) { + wideEvent.status_code = 404; + wideEvent.outcome = "user_not_found"; + wideEvent.error = { message: "User not found" }; + return new Response("User not found", { status: 404 }); + } + + wideEvent.user = { id: user.id, email: user.email }; + + const teamIdParam = request.nextUrl.searchParams.get("teamId"); + const playerNameParam = request.nextUrl.searchParams.get("playerName"); + + if (!teamIdParam) { + wideEvent.status_code = 400; + wideEvent.outcome = "missing_team_id"; + wideEvent.error = { message: "Team ID is required" }; + return new Response("Team ID is required", { status: 400 }); + } + + const teamId = parseInt(teamIdParam); + if (isNaN(teamId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team_id"; + wideEvent.error = { message: "Invalid team ID" }; + return new Response("Invalid team ID", { status: 400 }); + } + + wideEvent.team = { id: teamId }; + wideEvent.filters = { + player_name: playerNameParam, + }; + + const groups = await prisma.comparisonGroup.findMany({ + where: { + teamId, + ...(playerNameParam + ? { playerName: { equals: playerNameParam, mode: "insensitive" } } + : {}), + }, + include: { + creator: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const formattedGroups = groups.map((group) => ({ + id: group.id, + name: group.name, + description: group.description, + playerName: group.playerName, + heroes: group.heroes, + mapIds: group.mapIds, + mapCount: group.mapIds.length, + createdBy: group.creator.name ?? group.creator.email, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + })); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + group_count: formattedGroups.length, + filtered_by_player: !!playerNameParam, + }; + + return NextResponse.json({ + success: true, + groups: formattedGroups, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching comparison groups", error); + return NextResponse.json( + { + success: false, + error: "Failed to fetch comparison groups", + }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} + export async function POST(request: NextRequest) { const startTime = Date.now(); const wideEvent: Record = { From 94f10a49eedc9a72c3d46d0d1713c91d99771753 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:40:58 -0500 Subject: [PATCH 005/103] Add map card and comparison button localization strings to en.json --- messages/en.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/messages/en.json b/messages/en.json index d80d1ad8d..d4612e8a0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -684,6 +684,23 @@ }, "code": "Replay code: {replayCode}" }, + "mapCard": { + "altText": "The loading screen art for {map}.", + "selected": "Selected", + "copiedCode": "Copied replay code!", + "contextMenu": { + "title": "Map Actions", + "selectForComparison": "Select for comparison", + "viewDetails": "View Map Details", + "copyCode": "Copy Replay Code" + } + }, + "compareButton": { + "selected": "{count, plural, =1 {1 map selected} other {# maps selected}}", + "fromScrims": "{count, plural, =1 {from 1 scrim} other {from # scrims}}", + "compare": "Compare Selected", + "clear": "Clear" + }, "viewStats": "View team stats" }, "mapPage": { From 72bcc17fe1a9fffa9bad988b669697364f596d32 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:41:17 -0500 Subject: [PATCH 006/103] Add CompareSelectedButton component for map selection comparison functionality --- .../scrim/compare-selected-button.tsx | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/components/scrim/compare-selected-button.tsx diff --git a/src/components/scrim/compare-selected-button.tsx b/src/components/scrim/compare-selected-button.tsx new file mode 100644 index 000000000..8482e006e --- /dev/null +++ b/src/components/scrim/compare-selected-button.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + mapSelectionStore, + selectHasSelections, + selectSelectedMapIds, + selectSelectionCount, + selectUniqueScrimCount, +} from "@/stores/map-selection-store"; +import { useSelector } from "@xstate/store/react"; +import type { Route } from "next"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo } from "react"; + +type CompareSelectedButtonProps = { + teamId: number; +}; + +export function CompareSelectedButton({ teamId }: CompareSelectedButtonProps) { + const t = useTranslations("scrimPage.compareButton"); + const router = useRouter(); + + // Memoize selector functions + const hasSelectionsSelector = useCallback( + (state: ReturnType) => + selectHasSelections(state.context), + [] + ); + + const selectionCountSelector = useCallback( + (state: ReturnType) => + selectSelectionCount(state.context), + [] + ); + + const selectedMapIdsSelector = useCallback( + (state: ReturnType) => + selectSelectedMapIds(state.context), + [] + ); + + const uniqueScrimCountSelector = useCallback( + (state: ReturnType) => + selectUniqueScrimCount(state.context), + [] + ); + + const hasSelections = useSelector(mapSelectionStore, hasSelectionsSelector); + const selectionCount = useSelector(mapSelectionStore, selectionCountSelector); + const selectedMapIdsMap = useSelector( + mapSelectionStore, + selectedMapIdsSelector + ); + const uniqueScrimCount = useSelector( + mapSelectionStore, + uniqueScrimCountSelector + ); + + // Convert Map to Array of map IDs + const selectedMapIds = useMemo( + () => Array.from(selectedMapIdsMap.keys()), + [selectedMapIdsMap] + ); + + const handleCompare = useCallback(() => { + const mapIdsParam = selectedMapIds.join(","); + const url = `/${teamId}/compare?maps=${mapIdsParam}` as Route; + router.push(url); + }, [selectedMapIds, teamId, router]); + + const handleClear = useCallback(() => { + mapSelectionStore.send({ type: "clearAll" }); + }, []); + + if (!hasSelections) return null; + + return ( +
+
+
+ + {t("selected", { count: selectionCount })} + + {uniqueScrimCount > 1 && ( + + {t("fromScrims", { count: uniqueScrimCount })} + + )} +
+ + +
+
+ ); +} From deeef7429322d427c79a8314562ef8991992ea61 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:41:26 -0500 Subject: [PATCH 007/103] Add MapCardWithSelection component for enhanced map selection and context menu functionality --- .../scrim/map-card-with-selection.tsx | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/components/scrim/map-card-with-selection.tsx diff --git a/src/components/scrim/map-card-with-selection.tsx b/src/components/scrim/map-card-with-selection.tsx new file mode 100644 index 000000000..56337475e --- /dev/null +++ b/src/components/scrim/map-card-with-selection.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { ReplayCode } from "@/components/scrim/replay-code"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Link } from "@/components/ui/link"; +import { cn, toKebabCase, useMapNames } from "@/lib/utils"; +import { + mapSelectionStore, + selectIsMapSelected, +} from "@/stores/map-selection-store"; +import type { Map } from "@prisma/client"; +import { useSelector } from "@xstate/store/react"; +import type { Route } from "next"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import { memo, useCallback } from "react"; +import { toast } from "sonner"; + +type MapCardWithSelectionProps = { + map: Map; + scrimId: number; + teamId: number; + locale: string; +}; + +function MapCardWithSelectionComponent({ + map, + scrimId, + teamId, +}: MapCardWithSelectionProps) { + const t = useTranslations("scrimPage.mapCard"); + + // Memoize selector function + const isSelectedSelector = useCallback( + (state: ReturnType) => + selectIsMapSelected(state.context, map.id), + [map.id] + ); + + const isSelected = useSelector(mapSelectionStore, isSelectedSelector); + + const handleToggleSelection = useCallback(() => { + mapSelectionStore.send({ + type: "toggleMapSelection", + mapId: map.id, + scrimId, + }); + }, [map.id, scrimId]); + + const handleCopyReplayCode = useCallback( + (e: Event) => { + e.preventDefault(); + if (map.replayCode) { + void navigator.clipboard.writeText(map.replayCode); + toast.success(t("copiedCode"), { + description: map.replayCode, + }); + } + }, + [map.replayCode, t] + ); + + // Get map display name + const mapNames = useMapNames(); + const displayName = mapNames.get(toKebabCase(map.name)) ?? map.name; + + return ( + + + + + +

+ {displayName} +

+
+ + {t("altText", + + + +
+ {map.replayCode && } +
+
+ + {/* Selection indicator badge */} + {isSelected && ( + + {t("selected")} + + )} +
+
+ + + {t("contextMenu.title")} + + + {t("contextMenu.selectForComparison")} + + + + + {t("contextMenu.viewDetails")} + + + {map.replayCode && ( + + {t("contextMenu.copyCode")} + + )} + +
+ ); +} + +// Memoize to prevent unnecessary re-renders +export const MapCardWithSelection = memo( + MapCardWithSelectionComponent, + (prev, next) => + prev.map.id === next.map.id && + prev.scrimId === next.scrimId && + prev.teamId === next.teamId && + prev.locale === next.locale +); From 7491ffeae08f164bd016ba4c0328631abf48e159 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:41:53 -0500 Subject: [PATCH 008/103] Add map selection store to manage map selections with localStorage persistence --- src/stores/map-selection-store.ts | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/stores/map-selection-store.ts diff --git a/src/stores/map-selection-store.ts b/src/stores/map-selection-store.ts new file mode 100644 index 000000000..b08e65d55 --- /dev/null +++ b/src/stores/map-selection-store.ts @@ -0,0 +1,166 @@ +import { createStore } from "@xstate/store"; + +type MapSelection = { + mapId: number; + scrimId: number; +}; + +type MapSelectionContext = { + // Map from mapId to scrimId + selections: Map; +}; + +const STORAGE_KEY = "parsertime:map-selections"; + +// Helper to safely access localStorage (SSR-safe) +function getStoredSelections(): Map { + if (typeof window === "undefined") { + return new Map(); + } + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return new Map(); + + const parsed = JSON.parse(stored) as [number, number][]; + return new Map(parsed); + } catch { + return new Map(); + } +} + +function saveSelections(selections: Map): void { + if (typeof window === "undefined") return; + + try { + const serialized = JSON.stringify(Array.from(selections.entries())); + localStorage.setItem(STORAGE_KEY, serialized); + } catch { + // Ignore storage errors + } +} + +function clearStoredSelections(): void { + if (typeof window === "undefined") return; + + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage errors + } +} + +export const mapSelectionStore = createStore({ + context: { + selections: getStoredSelections(), + } satisfies MapSelectionContext, + on: { + toggleMapSelection: ( + context: MapSelectionContext, + event: { mapId: number; scrimId: number } + ): MapSelectionContext => { + const newSelections = new Map(context.selections); + + if (newSelections.has(event.mapId)) { + newSelections.delete(event.mapId); + } else { + newSelections.set(event.mapId, event.scrimId); + } + + // Persist to localStorage + saveSelections(newSelections); + + return { + ...context, + selections: newSelections, + }; + }, + + selectAll: ( + context: MapSelectionContext, + event: { maps: MapSelection[] } + ): MapSelectionContext => { + const newSelections = new Map(context.selections); + + for (const { mapId, scrimId } of event.maps) { + newSelections.set(mapId, scrimId); + } + + // Persist to localStorage + saveSelections(newSelections); + + return { + ...context, + selections: newSelections, + }; + }, + + clearAll: (context: MapSelectionContext): MapSelectionContext => { + // Clear localStorage + clearStoredSelections(); + + return { + ...context, + selections: new Map(), + }; + }, + + clearScrim: ( + context: MapSelectionContext, + event: { scrimId: number } + ): MapSelectionContext => { + const newSelections = new Map(context.selections); + + for (const [mapId, scrimId] of newSelections.entries()) { + if (scrimId === event.scrimId) { + newSelections.delete(mapId); + } + } + + // Persist to localStorage + saveSelections(newSelections); + + return { + ...context, + selections: newSelections, + }; + }, + }, +}); + +// Selectors +export function selectIsMapSelected(state: MapSelectionContext, mapId: number) { + return state.selections.has(mapId); +} + +export function selectHasSelections(state: MapSelectionContext) { + return state.selections.size > 0; +} + +export function selectSelectedMapIds(state: MapSelectionContext) { + return state.selections; +} + +export function selectSelectionCount(state: MapSelectionContext) { + return state.selections.size; +} + +export function selectUniqueScrimCount(state: MapSelectionContext) { + const scrimIds = new Set(); + for (const scrimId of state.selections.values()) { + scrimIds.add(scrimId); + } + return scrimIds.size; +} + +export function selectMapsByScrim(state: MapSelectionContext) { + const mapsByScrim = new Map(); + + for (const [mapId, scrimId] of state.selections.entries()) { + const maps = mapsByScrim.get(scrimId) ?? []; + maps.push(mapId); + mapsByScrim.set(scrimId, maps); + } + + return mapsByScrim; +} From 576c78d4fe1226d3c6dcd7c0c9c514193ef91521 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:41:59 -0500 Subject: [PATCH 009/103] Refactor ScrimDashboardPage to enhance layout and integrate MapCardWithSelection and CompareSelectedButton components --- src/app/[team]/scrim/[scrimId]/page.tsx | 184 +++++++++++------------- 1 file changed, 83 insertions(+), 101 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx index 1624f903c..9a3274c36 100644 --- a/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/page.tsx @@ -1,14 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { AddMapCard } from "@/components/map/add-map"; import { ClientDate } from "@/components/scrim/client-date"; -import { ReplayCode } from "@/components/scrim/replay-code"; +import { CompareSelectedButton } from "@/components/scrim/compare-selected-button"; +import { MapCardWithSelection } from "@/components/scrim/map-card-with-selection"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from "@/components/ui/card"; import { Link } from "@/components/ui/link"; import { Tooltip, @@ -19,13 +14,11 @@ import { getScrim } from "@/data/scrim-dto"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { getMapNames, toKebabCase } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@prisma/client"; import { ExclamationTriangleIcon, Pencil2Icon } from "@radix-ui/react-icons"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; -import Image from "next/image"; import { notFound } from "next/navigation"; export async function generateMetadata( @@ -79,7 +72,9 @@ export default async function ScrimDashboardPage( const t = await getTranslations("scrimPage"); const scrim = await getScrim(id); - if (!scrim) notFound(); + if (!scrim?.teamId) notFound(); + + const teamId = scrim.teamId; // TypeScript knows it's not null after the check const maps = ( await prisma.map.findMany({ @@ -94,7 +89,7 @@ export default async function ScrimDashboardPage( const isManager = (await prisma.teamManager.findFirst({ where: { - teamId: scrim?.teamId ?? 0, + teamId, userId: user?.id, }, })) !== null && session !== null; @@ -114,105 +109,92 @@ export default async function ScrimDashboardPage( }, })) ?? { guestMode: false }; - const mapNames = await getMapNames(); - return ( -
-

- ← {t("back")} - {" | "} - - {t("viewStats")} → - -

-
-

- - {scrim?.name ?? t("newScrim")}{" "} - {hasPerms && ( - - - - - - {t("edit")} - - - )} - -

-
-

- -

-

- {t("maps.title")} -

- {maps.length > 0 ? ( -
- {maps.map((map) => ( -
- +
+ {/* Header Section */} +
+

+ ← {t("back")} + {" | "} + + {t("viewStats")} → + +

+
+

+ + {scrim?.name ?? t("newScrim")}{" "} + {hasPerms && ( - -

- {mapNames.get(toKebabCase(map.name)) ?? map.name} -

-
- - {t("maps.altText", - + + + + + {t("edit")} + - -
- {map.replayCode && ( - - )} -
-
- -

- ))} - {hasPerms && } + )} + + +
+

+ +

+
+ + {/* Maps Section */} +
+
+

+ {t("maps.title")} +

- ) : ( - <> - - - {t("noMaps.title")} - - {t("noMaps.description")} - - {t("noMaps.link")} - - . - - -
{hasPerms && }
- - )} + + {maps.length > 0 ? ( +
+ {maps.map((map) => ( + + ))} + {hasPerms && } +
+ ) : ( + <> + + + {t("noMaps.title")} + + {t("noMaps.description")} + + {t("noMaps.link")} + + . + + +
{hasPerms && }
+ + )} +
+ + {/* Compare Selected Button */} + ); } From eebfc4818e8fc971c97008c55efc54639c5bcb44 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:55:25 -0500 Subject: [PATCH 010/103] Add localization strings for the new Compare Maps feature in en.json --- messages/en.json | 132 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/messages/en.json b/messages/en.json index d4612e8a0..40b3a8a21 100644 --- a/messages/en.json +++ b/messages/en.json @@ -703,6 +703,138 @@ }, "viewStats": "View team stats" }, + "comparePage": { + "metadata": { + "title": "Compare Maps | Parsertime", + "description": "Compare player performance across multiple maps" + }, + "title": "Compare Maps", + "subtitle": "Analyze player performance across selected maps", + "comparingMaps": "{count, plural, =1 {Comparing 1 map} other {Comparing # maps}}", + "loading": "Loading comparison data...", + "emptyStates": { + "noMaps": { + "title": "No Maps Selected", + "description": "Select maps from the scrim page to start comparing player performance." + }, + "noPlayer": { + "title": "No Player Selected", + "description": "Select a player to start comparing their performance across maps." + }, + "noData": { + "title": "No Data Available", + "description": "{player} did not participate in the selected maps." + } + }, + "views": { + "sideBySide": "Side by Side", + "delta": "Delta", + "trends": "Trends", + "charts": "Charts" + }, + "filters": { + "title": "Comparison Filters", + "resetAll": "Reset All", + "playerLabel": "Player", + "heroesLabel": "Heroes", + "player": "Player", + "heroesCount": "{count, plural, =1 {1 hero} other {# heroes}}" + }, + "playerSelector": { + "placeholder": "Select player...", + "search": "Search players...", + "noPlayerFound": "No player found.", + "loading": "Loading...", + "mapCount": "{count, plural, =1 {1 map} other {# maps}}" + }, + "sideBySide": { + "requiresTwoMaps": "Side-by-side comparison requires exactly 2 maps.", + "statComparison": "Stat Comparison", + "stats": { + "eliminations": "Eliminations", + "deaths": "Deaths", + "damage": "Damage", + "healing": "Healing", + "mitigated": "Damage Mitigated", + "eliminationsPer10": "Eliminations per 10", + "deathsPer10": "Deaths per 10", + "damagePer10": "Damage per 10", + "healingPer10": "Healing per 10", + "mitigatedPer10": "Mitigated per 10" + } + }, + "delta": { + "requiresTwoMaps": "Delta view requires exactly 2 maps.", + "from": "From", + "to": "To", + "previous": "Previous", + "significant": "Significant", + "summary": "Summary of Significant Changes", + "noSignificantChanges": "No significant changes detected (threshold: ±10%)", + "stats": { + "eliminations": "Eliminations", + "deaths": "Deaths", + "damage": "Damage", + "healing": "Healing", + "mitigated": "Damage Mitigated", + "eliminationsPer10": "Eliminations per 10", + "deathsPer10": "Deaths per 10", + "damagePer10": "Damage per 10", + "healingPer10": "Healing per 10", + "mitigatedPer10": "Mitigated per 10" + } + }, + "trends": { + "requiresThreePlus": "Trends view requires 3 or more maps.", + "aggregateSummary": "Aggregate Summary", + "totalMaps": "Total Maps", + "avgElimsPer10": "Avg Elims/10", + "avgDeathsPer10": "Avg Deaths/10", + "avgDamagePer10": "Avg Damage/10", + "performanceProgression": "Performance Progression", + "firstHalf": "First Half", + "secondHalf": "Second Half", + "maps": "Maps", + "improvement": "Improvement", + "improvementDesc": "Performance increased in the second half of maps.", + "decline": "Decline", + "declineDesc": "Performance decreased in the second half of maps.", + "stable": "Stable", + "stableDesc": "Performance remained consistent across all maps.", + "bestPerformance": "Best Performance", + "needsImprovement": "Needs Improvement", + "elimsPer10": "Elims/10", + "deathsPer10": "Deaths/10", + "damagePer10": "Damage/10", + "perMapBreakdown": "Per-Map Breakdown", + "elims": "Elims/10", + "deaths": "Deaths/10", + "damage": "Damage/10" + }, + "charts": { + "performanceProgression": "Performance Progression", + "statComparison": "Stat Comparison", + "performanceProfile": "Performance Profile", + "totalEliminations": "Total Eliminations", + "totalDeaths": "Total Deaths", + "totalDamage": "Total Damage", + "avgPer10": "Avg per 10", + "eliminations": "Eliminations", + "deaths": "Deaths", + "damage": "Damage (K)", + "healing": "Healing (K)", + "mitigated": "Mitigated (K)", + "elimsPer10": "Elims/10", + "deathsPer10": "Deaths/10", + "damagePer10K": "Damage/10 (K)", + "elimsPer10Short": "Elims", + "deathsPer10Short": "Deaths", + "damagePer10Short": "Damage", + "healingPer10Short": "Healing", + "mitigatedPer10Short": "Mitigated", + "averagePerformance": "Average Performance" + } + }, "mapPage": { "mapMetadata": { "title": "{mapName} Overview | Parsertime", From fb2f775478375176123e625b484cabb780c5eea4 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:56:23 -0500 Subject: [PATCH 011/103] Add ComparePage component for team comparison functionality with user authentication and metadata generation --- src/app/[team]/compare/page.tsx | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/app/[team]/compare/page.tsx diff --git a/src/app/[team]/compare/page.tsx b/src/app/[team]/compare/page.tsx new file mode 100644 index 000000000..9aa746649 --- /dev/null +++ b/src/app/[team]/compare/page.tsx @@ -0,0 +1,68 @@ +import { ComparisonContent } from "@/components/compare/comparison-content"; +import { DashboardLayout } from "@/components/dashboard-layout"; +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import type { PagePropsWithLocale } from "@/types/next"; +import { $Enums } from "@prisma/client"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export async function generateMetadata( + props: PagePropsWithLocale<"/[team]/compare"> +): Promise { + const params = await props.params; + const t = await getTranslations({ + locale: params.locale, + namespace: "comparePage.metadata", + }); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default async function ComparePage( + props: PagePropsWithLocale<"/[team]/compare"> +) { + const params = await props.params; + const session = await auth(); + + if (!session?.user?.email) { + notFound(); + } + + const user = await getUser(session.user.email); + if (!user) { + notFound(); + } + + // Extract team ID from team slug + const teamId = parseInt(params.team); + if (isNaN(teamId)) { + notFound(); + } + + // Verify user has access to this team + const team = await prisma.team.findUnique({ + where: { id: teamId }, + include: { + users: true, + }, + }); + + if ( + user.role !== $Enums.UserRole.ADMIN && + (!team || !team.users.some((teamUser) => teamUser.id === user.id)) + ) { + notFound(); + } + + return ( + + + + ); +} From 86857383c89f6fb35cd3a3731367f41f39112d66 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:56:36 -0500 Subject: [PATCH 012/103] Add ChartsView component for visualizing player performance metrics with line, bar, and radar charts --- src/components/compare/charts-view.tsx | 444 +++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 src/components/compare/charts-view.tsx diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx new file mode 100644 index 000000000..1a569c8d8 --- /dev/null +++ b/src/components/compare/charts-view.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { HeroName } from "@/types/heroes"; +import { useTranslations } from "next-intl"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, + XAxis, + YAxis, +} from "recharts"; + +type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: { + eliminations: number; + deaths: number; + damage: number; + healing: number; + mitigated: number; + heroTimePlayed: number; + eliminationsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; + mitigatedPer10: number; + }; + perMapBreakdown: { + mapId: number; + mapName: string; + date: Date; + heroes: HeroName[]; + stats: Record; + }[]; +}; + +type ChartsViewProps = { + stats: ComparisonStats; + viewMode: "two-map" | "multi-map"; +}; + +export function ChartsView({ stats, viewMode }: ChartsViewProps) { + const t = useTranslations("comparePage.charts"); + + // Prepare data for line chart (multi-map progression) + const lineChartData = stats.perMapBreakdown.map((map, index) => ({ + name: `Map ${index + 1}`, + fullName: map.mapName, + elimsPer10: Number((map.stats.eliminationsPer10 ?? 0).toFixed(2)), + deathsPer10: Number((map.stats.deathsPer10 ?? 0).toFixed(2)), + damagePer10: Number(((map.stats.damagePer10 ?? 0) / 1000).toFixed(2)), // Scale for better visualization + })); + + const lineChartConfig: ChartConfig = { + elimsPer10: { + label: t("elimsPer10"), + color: "hsl(var(--chart-1))", + }, + deathsPer10: { + label: t("deathsPer10"), + color: "hsl(var(--chart-2))", + }, + damagePer10: { + label: t("damagePer10K"), + color: "hsl(var(--chart-3))", + }, + }; + + // Prepare data for bar chart (side-by-side comparison for 2 maps) + const barChartData = + viewMode === "two-map" && stats.perMapBreakdown.length === 2 + ? [ + { + stat: t("eliminations"), + map1: stats.perMapBreakdown[0].stats.eliminations ?? 0, + map2: stats.perMapBreakdown[1].stats.eliminations ?? 0, + }, + { + stat: t("deaths"), + map1: stats.perMapBreakdown[0].stats.deaths ?? 0, + map2: stats.perMapBreakdown[1].stats.deaths ?? 0, + }, + { + stat: t("damage"), + map1: (stats.perMapBreakdown[0].stats.damage ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.damage ?? 0) / 1000, + }, + { + stat: t("healing"), + map1: (stats.perMapBreakdown[0].stats.healing ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.healing ?? 0) / 1000, + }, + { + stat: t("mitigated"), + map1: (stats.perMapBreakdown[0].stats.mitigated ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.mitigated ?? 0) / 1000, + }, + ] + : []; + + const barChartConfig: ChartConfig = + viewMode === "two-map" && stats.perMapBreakdown.length === 2 + ? { + map1: { + label: stats.perMapBreakdown[0].mapName, + color: "hsl(var(--chart-1))", + }, + map2: { + label: stats.perMapBreakdown[1].mapName, + color: "hsl(var(--chart-2))", + }, + } + : {}; + + // Prepare data for radar chart (performance profile) + const radarChartData = + viewMode === "two-map" && stats.perMapBreakdown.length === 2 + ? [ + { + metric: t("elimsPer10Short"), + map1: Number( + (stats.perMapBreakdown[0].stats.eliminationsPer10 ?? 0).toFixed(2) + ), + map2: Number( + (stats.perMapBreakdown[1].stats.eliminationsPer10 ?? 0).toFixed(2) + ), + }, + { + metric: t("deathsPer10Short"), + map1: Number( + (20 - (stats.perMapBreakdown[0].stats.deathsPer10 ?? 0)).toFixed( + 2 + ) + ), // Invert for better visualization + map2: Number( + (20 - (stats.perMapBreakdown[1].stats.deathsPer10 ?? 0)).toFixed( + 2 + ) + ), + }, + { + metric: t("damagePer10Short"), + map1: Number( + ( + (stats.perMapBreakdown[0].stats.damagePer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1].stats.damagePer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + { + metric: t("healingPer10Short"), + map1: Number( + ( + (stats.perMapBreakdown[0].stats.healingPer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1].stats.healingPer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + { + metric: t("mitigatedPer10Short"), + map1: Number( + ( + (stats.perMapBreakdown[0].stats.mitigatedPer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1].stats.mitigatedPer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + ] + : [ + { + metric: t("elimsPer10Short"), + value: Number(stats.aggregated.eliminationsPer10.toFixed(2)), + }, + { + metric: t("deathsPer10Short"), + value: Number((20 - stats.aggregated.deathsPer10).toFixed(2)), // Invert + }, + { + metric: t("damagePer10Short"), + value: Number((stats.aggregated.damagePer10 / 1000).toFixed(2)), + }, + { + metric: t("healingPer10Short"), + value: Number((stats.aggregated.healingPer10 / 1000).toFixed(2)), + }, + { + metric: t("mitigatedPer10Short"), + value: Number((stats.aggregated.mitigatedPer10 / 1000).toFixed(2)), + }, + ]; + + const radarChartConfig: ChartConfig = + viewMode === "two-map" && stats.perMapBreakdown.length === 2 + ? { + map1: { + label: stats.perMapBreakdown[0].mapName, + color: "hsl(var(--chart-1))", + }, + map2: { + label: stats.perMapBreakdown[1].mapName, + color: "hsl(var(--chart-2))", + }, + } + : { + value: { + label: t("averagePerformance"), + color: "hsl(var(--chart-1))", + }, + }; + + return ( +
+ {/* Line Chart - Progression (Multi-map only) */} + {viewMode === "multi-map" && ( + + + {t("performanceProgression")} + + + + + + + + { + const item = payload?.[0]?.payload as + | { fullName?: string } + | undefined; + return item?.fullName ?? ""; + }} + /> + } + /> + } /> + + + + + + + + )} + + {/* Bar Chart - Side by Side (Two-map only) */} + {viewMode === "two-map" && barChartData.length > 0 && ( + + + {t("statComparison")} + + + + + + + + } /> + } /> + + + + + + + )} + + {/* Radar Chart - Performance Profile */} + + + {t("performanceProfile")} + + + + + + + + } /> + {viewMode === "two-map" && stats.perMapBreakdown.length === 2 ? ( + <> + + + } /> + + ) : ( + + )} + + + + + + {/* Additional Stats Cards */} +
+ + + + {t("totalEliminations")} + + + +
+ {stats.aggregated.eliminations.toLocaleString()} +
+

+ {t("avgPer10")}: {stats.aggregated.eliminationsPer10.toFixed(2)} +

+
+
+ + + + + {t("totalDeaths")} + + + +
+ {stats.aggregated.deaths.toLocaleString()} +
+

+ {t("avgPer10")}: {stats.aggregated.deathsPer10.toFixed(2)} +

+
+
+ + + + + {t("totalDamage")} + + + +
+ {stats.aggregated.damage.toLocaleString()} +
+

+ {t("avgPer10")}: {stats.aggregated.damagePer10.toLocaleString()} +

+
+
+
+
+ ); +} From f64784d5e28ed784c7b2883e20772edbe0cc01be Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:56:42 -0500 Subject: [PATCH 013/103] Add ComparisonContent component to handle player comparison views, including filters and dynamic content loading based on selected maps and players --- src/components/compare/comparison-content.tsx | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/components/compare/comparison-content.tsx diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx new file mode 100644 index 000000000..e404ea7ef --- /dev/null +++ b/src/components/compare/comparison-content.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { HeroName } from "@/types/heroes"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { ChartsView } from "./charts-view"; +import { ComparisonFilters } from "./comparison-filters"; +import { DeltaView } from "./delta-view"; +import { EmptyState } from "./empty-state"; +import { SideBySideView } from "./side-by-side-view"; +import { TrendsView } from "./trends-view"; + +type ComparisonContentProps = { + teamId: number; + locale: string; +}; + +type ViewMode = "side-by-side" | "delta" | "trends" | "charts"; + +type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: { + eliminations: number; + deaths: number; + damage: number; + healing: number; + mitigated: number; + heroTimePlayed: number; + eliminationsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; + mitigatedPer10: number; + }; + perMapBreakdown: { + mapId: number; + mapName: string; + date: Date; + heroes: HeroName[]; + stats: Record; + }[]; + trends?: { + improvingMetrics: string[]; + decliningMetrics: string[]; + earlyPerformance?: Record; + latePerformance?: Record; + }; + heroBreakdown?: Record>; +}; + +async function fetchComparisonStats( + mapIds: number[], + playerName: string, + heroes?: HeroName[] +): Promise { + const params = new URLSearchParams({ + mapIds: JSON.stringify(mapIds), + playerName, + }); + + if (heroes && heroes.length > 0) { + params.set("heroes", heroes.join(",")); + } + + const response = await fetch(`/api/compare/stats?${params.toString()}`); + if (!response.ok) { + throw new Error("Failed to fetch comparison stats"); + } + + const data = (await response.json()) as { data: ComparisonStats }; + return data.data; +} + +export function ComparisonContent({ teamId }: ComparisonContentProps) { + const t = useTranslations("comparePage"); + const searchParams = useSearchParams(); + + // Get map IDs from URL + const mapsParam = searchParams.get("maps"); + const selectedMapIds = mapsParam + ? mapsParam.split(",").map((id) => parseInt(id, 10)) + : []; + + // Filter state + const [selectedPlayer, setSelectedPlayer] = useState(null); + const [selectedHeroes, setSelectedHeroes] = useState([]); + const [dateRange, setDateRange] = useState< + { from: Date; to: Date } | undefined + >(undefined); + + // View mode state + const [activeView, setActiveView] = useState("side-by-side"); + + // Fetch comparison stats + const { data: comparisonStats, isLoading } = useQuery({ + queryKey: [ + "comparisonStats", + selectedMapIds, + selectedPlayer, + selectedHeroes, + ], + queryFn: () => + fetchComparisonStats( + selectedMapIds, + selectedPlayer!, + selectedHeroes.length > 0 ? selectedHeroes : undefined + ), + enabled: selectedMapIds.length > 0 && !!selectedPlayer, + staleTime: 5 * 60 * 1000, + }); + + // Determine available views based on map count + const availableViews: ViewMode[] = useMemo(() => { + return selectedMapIds.length === 2 + ? ["side-by-side", "delta", "charts"] + : selectedMapIds.length >= 3 + ? ["trends", "charts"] + : []; + }, [selectedMapIds.length]); + + // Auto-switch view when map selection changes + useEffect(() => { + if (availableViews.length > 0 && !availableViews.includes(activeView)) { + setActiveView(availableViews[0]); + } + }, [availableViews, activeView]); + + // Empty state when no maps selected + if (selectedMapIds.length === 0) { + return ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+

{t("title")}

+

+ {t("comparingMaps", { count: selectedMapIds.length })} +

+
+ + {/* Filters */} + + + {/* Content */} + {!selectedPlayer ? ( + + ) : isLoading ? ( + +
+ + {t("loading")} +
+
+ ) : !comparisonStats ? ( + + ) : ( + setActiveView(v as ViewMode)} + > + + {availableViews.includes("side-by-side") && ( + + {t("views.sideBySide")} + + )} + {availableViews.includes("delta") && ( + {t("views.delta")} + )} + {availableViews.includes("trends") && ( + {t("views.trends")} + )} + {availableViews.includes("charts") && ( + {t("views.charts")} + )} + + + {availableViews.includes("side-by-side") && ( + + + + )} + + {availableViews.includes("delta") && ( + + + + )} + + {availableViews.includes("trends") && ( + + + + )} + + {availableViews.includes("charts") && ( + + + + )} + + )} +
+ ); +} From f2b06ffa4b5b37f25a34638fa65da2696a0852ea Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:56:47 -0500 Subject: [PATCH 014/103] Add ComparisonFilters component for managing player and hero selection in comparison views, including reset functionality and active filters display --- src/components/compare/comparison-filters.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/components/compare/comparison-filters.tsx diff --git a/src/components/compare/comparison-filters.tsx b/src/components/compare/comparison-filters.tsx new file mode 100644 index 000000000..ecf531bd2 --- /dev/null +++ b/src/components/compare/comparison-filters.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { HeroFilter } from "@/components/stats/player/hero-filter"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { HeroName } from "@/types/heroes"; +import { RotateCcw, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { PlayerSelector } from "./player-selector"; + +type ComparisonFiltersProps = { + teamId: number; + selectedPlayer: string | null; + selectedHeroes: HeroName[]; + dateRange?: { from: Date; to: Date }; + onPlayerChange: (player: string | null) => void; + onHeroesChange: (heroes: HeroName[]) => void; + onDateRangeChange: (range: { from: Date; to: Date } | undefined) => void; +}; + +export function ComparisonFilters({ + teamId, + selectedPlayer, + selectedHeroes, + onPlayerChange, + onHeroesChange, +}: ComparisonFiltersProps) { + const t = useTranslations("comparePage.filters"); + + const hasActiveFilters = useMemo(() => { + return selectedPlayer !== null || selectedHeroes.length > 0; + }, [selectedPlayer, selectedHeroes]); + + function handleReset() { + onPlayerChange(null); + onHeroesChange([]); + } + + return ( + + +
+ {t("title")} + +
+
+ +
+ {/* Player Selector */} +
+ + +
+ + {/* Hero Filter */} +
+ + +
+
+ + {/* Active Filters Summary */} + {hasActiveFilters && ( +
+ {selectedPlayer && ( + + {t("player")}: {selectedPlayer} + onPlayerChange(null)} + /> + + )} + {selectedHeroes.length > 0 && ( + + {selectedHeroes.length === 1 + ? selectedHeroes[0] + : t("heroesCount", { count: selectedHeroes.length })} + onHeroesChange([])} + /> + + )} +
+ )} +
+
+ ); +} From 7e972ecdf61e085b60e1a55d6ef54927f9b0425e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:56:57 -0500 Subject: [PATCH 015/103] Add DeltaView component to display comparative statistics between two maps --- src/components/compare/delta-view.tsx | 349 ++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/components/compare/delta-view.tsx diff --git a/src/components/compare/delta-view.tsx b/src/components/compare/delta-view.tsx new file mode 100644 index 000000000..6d3aeaa9a --- /dev/null +++ b/src/components/compare/delta-view.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: { + eliminations: number; + deaths: number; + damage: number; + healing: number; + mitigated: number; + heroTimePlayed: number; + eliminationsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; + mitigatedPer10: number; + }; + perMapBreakdown: { + mapId: number; + mapName: string; + date: Date; + heroes: HeroName[]; + stats: Record; + }[]; +}; + +type DeltaViewProps = { + stats: ComparisonStats; +}; + +type DeltaStat = { + label: string; + oldValue: number; + newValue: number; + format: "number" | "per10" | "time" | "percentage"; + reverseColors?: boolean; + significant?: boolean; +}; + +function formatStat( + value: number, + format: "number" | "per10" | "time" | "percentage" +): string { + if (format === "time") { + const hours = Math.floor(value / 3600); + const minutes = Math.floor((value % 3600) / 60); + const seconds = Math.floor(value % 60); + return hours > 0 + ? `${hours}h ${minutes}m ${seconds}s` + : `${minutes}m ${seconds}s`; + } + if (format === "per10") { + return value.toFixed(2); + } + if (format === "percentage") { + return `${value.toFixed(1)}%`; + } + return value.toLocaleString(); +} + +function calculateDelta(oldValue: number, newValue: number) { + const absoluteChange = newValue - oldValue; + const percentageChange = + oldValue !== 0 ? ((newValue - oldValue) / oldValue) * 100 : 0; + + return { + absolute: absoluteChange, + percentage: percentageChange, + isIncrease: absoluteChange > 0, + isSignificant: Math.abs(percentageChange) >= 10, + }; +} + +export function DeltaView({ stats }: DeltaViewProps) { + const t = useTranslations("comparePage.delta"); + + if (stats.perMapBreakdown.length !== 2) { + return ( + + +

{t("requiresTwoMaps")}

+
+
+ ); + } + + const [map1, map2] = stats.perMapBreakdown; + + const deltaStats: DeltaStat[] = [ + { + label: t("stats.eliminations"), + oldValue: map1.stats.eliminations ?? 0, + newValue: map2.stats.eliminations ?? 0, + format: "number", + }, + { + label: t("stats.deaths"), + oldValue: map1.stats.deaths ?? 0, + newValue: map2.stats.deaths ?? 0, + format: "number", + reverseColors: true, + }, + { + label: t("stats.damage"), + oldValue: map1.stats.damage ?? 0, + newValue: map2.stats.damage ?? 0, + format: "number", + }, + { + label: t("stats.healing"), + oldValue: map1.stats.healing ?? 0, + newValue: map2.stats.healing ?? 0, + format: "number", + }, + { + label: t("stats.mitigated"), + oldValue: map1.stats.mitigated ?? 0, + newValue: map2.stats.mitigated ?? 0, + format: "number", + }, + { + label: t("stats.eliminationsPer10"), + oldValue: map1.stats.eliminationsPer10 ?? 0, + newValue: map2.stats.eliminationsPer10 ?? 0, + format: "per10", + }, + { + label: t("stats.deathsPer10"), + oldValue: map1.stats.deathsPer10 ?? 0, + newValue: map2.stats.deathsPer10 ?? 0, + format: "per10", + reverseColors: true, + }, + { + label: t("stats.damagePer10"), + oldValue: map1.stats.damagePer10 ?? 0, + newValue: map2.stats.damagePer10 ?? 0, + format: "per10", + }, + { + label: t("stats.healingPer10"), + oldValue: map1.stats.healingPer10 ?? 0, + newValue: map2.stats.healingPer10 ?? 0, + format: "per10", + }, + { + label: t("stats.mitigatedPer10"), + oldValue: map1.stats.mitigatedPer10 ?? 0, + newValue: map2.stats.mitigatedPer10 ?? 0, + format: "per10", + }, + ]; + + return ( +
+ {/* Header Cards */} +
+ + +
+
+ {t("from")} +
+ {map1.mapName} + + {new Date(map1.date).toLocaleDateString()} + +
+
+
+ + + +
+
+ {t("to")} +
+ {map2.mapName} + + {new Date(map2.date).toLocaleDateString()} + +
+
+
+
+ + {/* Delta Cards */} +
+ {deltaStats.map((stat) => { + const delta = calculateDelta(stat.oldValue, stat.newValue); + const isImprovement = stat.reverseColors + ? !delta.isIncrease + : delta.isIncrease; + + const changeColor = cn( + delta.absolute === 0 + ? "text-muted-foreground" + : isImprovement + ? "text-green-600 dark:text-green-400" + : "text-red-600 dark:text-red-400" + ); + + const bgColor = cn( + delta.absolute === 0 + ? "bg-muted" + : isImprovement + ? "bg-green-50 dark:bg-green-950/20" + : "bg-red-50 dark:bg-red-950/20" + ); + + const Icon = delta.isIncrease ? TrendingUp : TrendingDown; + + return ( + + +
+
+

+ {stat.label} +

+
+ + {formatStat(stat.newValue, stat.format)} + + {delta.isSignificant && ( + + {t("significant")} + + )} +
+
+ {delta.absolute !== 0 && ( + + )} +
+
+ +
+
+ {t("previous")}: {formatStat(stat.oldValue, stat.format)} +
+
+ {delta.absolute > 0 ? "+" : ""} + {formatStat(Math.abs(delta.absolute), stat.format)} ( + {delta.percentage > 0 ? "+" : ""} + {delta.percentage.toFixed(1)}%) + {delta.isIncrease ? ( + + ) : ( + + )} +
+
+
+
+ ); + })} +
+ + {/* Summary */} + + + {t("summary")} + + +
+ {deltaStats + .filter((stat) => { + const delta = calculateDelta(stat.oldValue, stat.newValue); + return delta.isSignificant; + }) + .map((stat) => { + const delta = calculateDelta(stat.oldValue, stat.newValue); + const isImprovement = stat.reverseColors + ? !delta.isIncrease + : delta.isIncrease; + + return ( +
+
+ {delta.isIncrease ? ( + + ) : ( + + )} +
+
+

{stat.label}

+

+ {formatStat(stat.oldValue, stat.format)} →{" "} + {formatStat(stat.newValue, stat.format)} ( + {delta.percentage > 0 ? "+" : ""} + {delta.percentage.toFixed(1)}%) +

+
+
+ ); + })} + {deltaStats.filter((stat) => { + const delta = calculateDelta(stat.oldValue, stat.newValue); + return delta.isSignificant; + }).length === 0 && ( +

+ {t("noSignificantChanges")} +

+ )} +
+
+
+
+ ); +} From 67f1f92a05bd4033181c28ea47689bb6c8e4286a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:57:02 -0500 Subject: [PATCH 016/103] Add EmptyState component for displaying a customizable empty state with icons, title, and description in comparison views --- src/components/compare/empty-state.tsx | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/components/compare/empty-state.tsx diff --git a/src/components/compare/empty-state.tsx b/src/components/compare/empty-state.tsx new file mode 100644 index 000000000..c6f9b7537 --- /dev/null +++ b/src/components/compare/empty-state.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Loader2, MapPin, TrendingDown, UserX } from "lucide-react"; + +type EmptyStateProps = { + icon: "MapPin" | "UserX" | "TrendingDown" | "Loader"; + title: string; + description: string; +}; + +export function EmptyState({ icon, title, description }: EmptyStateProps) { + const Icon = + icon === "MapPin" + ? MapPin + : icon === "UserX" + ? UserX + : icon === "TrendingDown" + ? TrendingDown + : Loader2; + + return ( + + + +
+

{title}

+

{description}

+
+
+
+ ); +} From 4d9ace074a7d5b1852ea25d54f187cbd12d00456 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:57:16 -0500 Subject: [PATCH 017/103] Add SideBySideView component for detailed comparison of player statistics across two maps --- src/components/compare/side-by-side-view.tsx | 264 +++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/components/compare/side-by-side-view.tsx diff --git a/src/components/compare/side-by-side-view.tsx b/src/components/compare/side-by-side-view.tsx new file mode 100644 index 000000000..a083ffb23 --- /dev/null +++ b/src/components/compare/side-by-side-view.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { HeroName } from "@/types/heroes"; +import { ArrowDown, ArrowUp, Minus } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: { + eliminations: number; + deaths: number; + damage: number; + healing: number; + mitigated: number; + heroTimePlayed: number; + eliminationsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; + mitigatedPer10: number; + }; + perMapBreakdown: { + mapId: number; + mapName: string; + date: Date; + heroes: HeroName[]; + stats: Record; + }[]; +}; + +type SideBySideViewProps = { + stats: ComparisonStats; +}; + +type StatRow = { + label: string; + map1Value: number; + map2Value: number; + format: "number" | "per10" | "time"; + reverseColors?: boolean; +}; + +function formatStat( + value: number, + format: "number" | "per10" | "time" +): string { + if (format === "time") { + const hours = Math.floor(value / 3600); + const minutes = Math.floor((value % 3600) / 60); + const seconds = Math.floor(value % 60); + return hours > 0 + ? `${hours}h ${minutes}m ${seconds}s` + : `${minutes}m ${seconds}s`; + } + if (format === "per10") { + return value.toFixed(2); + } + return value.toLocaleString(); +} + +function getComparisonIndicator( + value1: number, + value2: number, + reverseColors = false +) { + const diff = value2 - value1; + const percentChange = value1 !== 0 ? (diff / value1) * 100 : 0; + + if (Math.abs(percentChange) < 1) { + return { + icon: Minus, + color: "text-muted-foreground", + bgColor: "bg-muted", + }; + } + + const isImprovement = reverseColors ? diff < 0 : diff > 0; + + return { + icon: diff > 0 ? ArrowUp : ArrowDown, + color: isImprovement + ? "text-green-600 dark:text-green-400" + : "text-red-600 dark:text-red-400", + bgColor: isImprovement + ? "bg-green-100 dark:bg-green-950" + : "bg-red-100 dark:bg-red-950", + }; +} + +export function SideBySideView({ stats }: SideBySideViewProps) { + const t = useTranslations("comparePage.sideBySide"); + + if (stats.perMapBreakdown.length !== 2) { + return ( + + +

{t("requiresTwoMaps")}

+
+
+ ); + } + + const [map1, map2] = stats.perMapBreakdown; + + const statRows: StatRow[] = [ + { + label: t("stats.eliminations"), + map1Value: map1.stats.eliminations ?? 0, + map2Value: map2.stats.eliminations ?? 0, + format: "number", + }, + { + label: t("stats.deaths"), + map1Value: map1.stats.deaths ?? 0, + map2Value: map2.stats.deaths ?? 0, + format: "number", + reverseColors: true, + }, + { + label: t("stats.damage"), + map1Value: map1.stats.damage ?? 0, + map2Value: map2.stats.damage ?? 0, + format: "number", + }, + { + label: t("stats.healing"), + map1Value: map1.stats.healing ?? 0, + map2Value: map2.stats.healing ?? 0, + format: "number", + }, + { + label: t("stats.mitigated"), + map1Value: map1.stats.mitigated ?? 0, + map2Value: map2.stats.mitigated ?? 0, + format: "number", + }, + { + label: t("stats.eliminationsPer10"), + map1Value: map1.stats.eliminationsPer10 ?? 0, + map2Value: map2.stats.eliminationsPer10 ?? 0, + format: "per10", + }, + { + label: t("stats.deathsPer10"), + map1Value: map1.stats.deathsPer10 ?? 0, + map2Value: map2.stats.deathsPer10 ?? 0, + format: "per10", + reverseColors: true, + }, + { + label: t("stats.damagePer10"), + map1Value: map1.stats.damagePer10 ?? 0, + map2Value: map2.stats.damagePer10 ?? 0, + format: "per10", + }, + { + label: t("stats.healingPer10"), + map1Value: map1.stats.healingPer10 ?? 0, + map2Value: map2.stats.healingPer10 ?? 0, + format: "per10", + }, + { + label: t("stats.mitigatedPer10"), + map1Value: map1.stats.mitigatedPer10 ?? 0, + map2Value: map2.stats.mitigatedPer10 ?? 0, + format: "per10", + }, + ]; + + return ( +
+ {/* Map Headers */} +
+ + + {map1.mapName} +
+ + {new Date(map1.date).toLocaleDateString()} + + {map1.heroes.length > 0 && ( + {map1.heroes.join(", ")} + )} +
+
+
+ + + + {map2.mapName} +
+ + {new Date(map2.date).toLocaleDateString()} + + {map2.heroes.length > 0 && ( + {map2.heroes.join(", ")} + )} +
+
+
+
+ + {/* Stats Table */} + + + {t("statComparison")} + + +
+ {statRows.map((row) => { + const indicator = getComparisonIndicator( + row.map1Value, + row.map2Value, + row.reverseColors + ); + const Icon = indicator.icon; + + return ( +
+ {/* Map 1 Value */} +
+ + {formatStat(row.map1Value, row.format)} + +
+ + {/* Indicator */} +
+ +
+ + {/* Stat Label */} +
+ {row.label} +
+ + {/* Indicator */} +
+ +
+ + {/* Map 2 Value */} +
+ + {formatStat(row.map2Value, row.format)} + +
+
+ ); + })} +
+
+
+
+ ); +} From 2e628fe204aa018e07078f1c08fa424d715c145c Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 20:57:26 -0500 Subject: [PATCH 018/103] Add TrendsView component to display detailed player performance statistics --- src/components/compare/trends-view.tsx | 414 +++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 src/components/compare/trends-view.tsx diff --git a/src/components/compare/trends-view.tsx b/src/components/compare/trends-view.tsx new file mode 100644 index 000000000..5e8b1fcee --- /dev/null +++ b/src/components/compare/trends-view.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { + AlertTriangle, + Minus, + TrendingDown, + TrendingUp, + Trophy, +} from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: { + eliminations: number; + deaths: number; + damage: number; + healing: number; + mitigated: number; + heroTimePlayed: number; + eliminationsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; + mitigatedPer10: number; + }; + perMapBreakdown: { + mapId: number; + mapName: string; + date: Date; + heroes: HeroName[]; + stats: Record; + }[]; + trends?: { + improvingMetrics: string[]; + decliningMetrics: string[]; + earlyPerformance?: Record; + latePerformance?: Record; + }; +}; + +type TrendsViewProps = { + stats: ComparisonStats; +}; + +export function TrendsView({ stats }: TrendsViewProps) { + const t = useTranslations("comparePage.trends"); + + if (stats.perMapBreakdown.length < 3) { + return ( + + +

{t("requiresThreePlus")}

+
+
+ ); + } + + // Find best and worst performing maps + const mapsByElimsPer10 = [...stats.perMapBreakdown].sort( + (a, b) => + (b.stats.eliminationsPer10 ?? 0) - (a.stats.eliminationsPer10 ?? 0) + ); + const bestMap = mapsByElimsPer10[0]; + const worstMap = mapsByElimsPer10[mapsByElimsPer10.length - 1]; + + // Calculate averages + const avgElimsPer10 = + stats.perMapBreakdown.reduce( + (sum, map) => sum + (map.stats.eliminationsPer10 ?? 0), + 0 + ) / stats.perMapBreakdown.length; + + const avgDeathsPer10 = + stats.perMapBreakdown.reduce( + (sum, map) => sum + (map.stats.deathsPer10 ?? 0), + 0 + ) / stats.perMapBreakdown.length; + + const avgDamagePer10 = + stats.perMapBreakdown.reduce( + (sum, map) => sum + (map.stats.damagePer10 ?? 0), + 0 + ) / stats.perMapBreakdown.length; + + // Calculate half comparisons if 4+ maps + const hasHalfComparison = stats.perMapBreakdown.length >= 4; + const halfPoint = Math.floor(stats.perMapBreakdown.length / 2); + const firstHalf = stats.perMapBreakdown.slice(0, halfPoint); + const secondHalf = stats.perMapBreakdown.slice(halfPoint); + + const firstHalfAvg = hasHalfComparison + ? firstHalf.reduce( + (sum, map) => sum + (map.stats.eliminationsPer10 ?? 0), + 0 + ) / firstHalf.length + : 0; + + const secondHalfAvg = hasHalfComparison + ? secondHalf.reduce( + (sum, map) => sum + (map.stats.eliminationsPer10 ?? 0), + 0 + ) / secondHalf.length + : 0; + + const halfDelta = secondHalfAvg - firstHalfAvg; + const halfDeltaPercent = + firstHalfAvg !== 0 ? (halfDelta / firstHalfAvg) * 100 : 0; + + return ( +
+ {/* Aggregate Summary */} + + + {t("aggregateSummary")} + + +
+
+

{t("totalMaps")}

+

+ {stats.mapCount} +

+
+
+

+ {t("avgElimsPer10")} +

+

+ {avgElimsPer10.toFixed(2)} +

+
+
+

+ {t("avgDeathsPer10")} +

+

+ {avgDeathsPer10.toFixed(2)} +

+
+
+

+ {t("avgDamagePer10")} +

+

+ {avgDamagePer10.toLocaleString()} +

+
+
+
+
+ + {/* Performance Progression */} + {hasHalfComparison && ( + + + {t("performanceProgression")} + + +
+ {/* First Half */} +
+
+ {t("firstHalf")} + + ({t("maps")} 1-{halfPoint}) + +
+
+

+ {t("avgElimsPer10")} +

+

+ {firstHalfAvg.toFixed(2)} +

+
+
+ + {/* Second Half */} +
+
+ {t("secondHalf")} + + ({t("maps")} {halfPoint + 1}-{stats.mapCount}) + +
+
+

+ {t("avgElimsPer10")} +

+

+ {secondHalfAvg.toFixed(2)} +

+
0 + ? "text-green-600 dark:text-green-400" + : halfDelta < 0 + ? "text-red-600 dark:text-red-400" + : "text-muted-foreground" + )} + > + {halfDelta > 0 ? ( + + ) : halfDelta < 0 ? ( + + ) : ( + + )} + + {halfDelta > 0 ? "+" : ""} + {halfDelta.toFixed(2)} ({halfDelta > 0 ? "+" : ""} + {halfDeltaPercent.toFixed(1)}%) + +
+
+
+
+ + {/* Interpretation */} +
+

+ {halfDelta > 5 ? ( + <> + + {t("improvement")}: + {" "} + {t("improvementDesc")} + + ) : halfDelta < -5 ? ( + <> + + {t("decline")}: + {" "} + {t("declineDesc")} + + ) : ( + <> + + {t("stable")}: + {" "} + {t("stableDesc")} + + )} +

+
+
+
+ )} + + {/* Best and Worst Maps */} +
+ {/* Best Map */} + + +
+ + + {t("bestPerformance")} + +
+
+ +
+
+

{bestMap.mapName}

+ + {new Date(bestMap.date).toLocaleDateString()} + +
+
+
+

+ {t("elimsPer10")} +

+

+ {(bestMap.stats.eliminationsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("deathsPer10")} +

+

+ {(bestMap.stats.deathsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("damagePer10")} +

+

+ {(bestMap.stats.damagePer10 ?? 0).toLocaleString()} +

+
+
+
+
+
+ + {/* Worst Map */} + + +
+ + + {t("needsImprovement")} + +
+
+ +
+
+

{worstMap.mapName}

+ + {new Date(worstMap.date).toLocaleDateString()} + +
+
+
+

+ {t("elimsPer10")} +

+

+ {(worstMap.stats.eliminationsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("deathsPer10")} +

+

+ {(worstMap.stats.deathsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("damagePer10")} +

+

+ {(worstMap.stats.damagePer10 ?? 0).toLocaleString()} +

+
+
+
+
+
+
+ + {/* Per-Map Breakdown */} + + + {t("perMapBreakdown")} + + +
+ {stats.perMapBreakdown.map((map, index) => ( +
+
+ + {index + 1} + +
+
+

{map.mapName}

+

+ {new Date(map.date).toLocaleDateString()} +

+
+
+
+

+ {t("elims")} +

+

+ {(map.stats.eliminationsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("deaths")} +

+

+ {(map.stats.deathsPer10 ?? 0).toFixed(2)} +

+
+
+

+ {t("damage")} +

+

+ {(map.stats.damagePer10 ?? 0).toLocaleString()} +

+
+
+
+ ))} +
+
+
+
+ ); +} From 962a24ab15453c081f7453a9f7b45f45bd25801e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:15:21 -0500 Subject: [PATCH 019/103] Add mapIds prop to ComparisonFilters for improved filtering functionality in ComparisonContent --- src/components/compare/comparison-content.tsx | 1 + src/components/compare/comparison-filters.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index e404ea7ef..885d2c11e 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -164,6 +164,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) { {/* Filters */} From 074feadcfbfef2ea19a3bd5880c39675dfd78084 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:15:30 -0500 Subject: [PATCH 020/103] Implement mapIds parameter handling in player comparison API for enhanced filtering and validation --- src/app/api/compare/players/route.ts | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/app/api/compare/players/route.ts b/src/app/api/compare/players/route.ts index 469c866c2..0d9ea2c3e 100644 --- a/src/app/api/compare/players/route.ts +++ b/src/app/api/compare/players/route.ts @@ -50,12 +50,40 @@ export async function GET(request: NextRequest) { wideEvent.team = { id: teamId }; - const players = await getTeamPlayers(teamId); + const mapIdsParam = request.nextUrl.searchParams.get("mapIds"); + let mapIds: number[] | undefined; + + if (mapIdsParam) { + try { + mapIds = JSON.parse(mapIdsParam) as number[]; + if ( + !Array.isArray(mapIds) || + !mapIds.every((id) => typeof id === "number") + ) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids"; + wideEvent.error = { message: "Map IDs must be an array of numbers" }; + return new Response("Map IDs must be an array of numbers", { + status: 400, + }); + } + } catch { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids_json"; + wideEvent.error = { message: "Map IDs must be valid JSON" }; + return new Response("Map IDs must be valid JSON", { status: 400 }); + } + } + + const players = await getTeamPlayers(teamId, mapIds); wideEvent.status_code = 200; wideEvent.outcome = "success"; wideEvent.result = { player_count: players.length, + map_ids: mapIds, + map_count: mapIds?.length ?? 0, + filtered: !!mapIds, }; return NextResponse.json({ From a03b6d7b311a2c7f1147aa5b4b0eca4e5de653d2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:15:36 -0500 Subject: [PATCH 021/103] Enhance player statistics retrieval by validating mapIds and counting unique maps per player in getTeamPlayersFn --- src/data/comparison-dto.ts | 50 +++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts index 69f84a300..00f174f18 100644 --- a/src/data/comparison-dto.ts +++ b/src/data/comparison-dto.ts @@ -750,8 +750,56 @@ export const getAvailableMapsForComparison = cache( ); async function getTeamPlayersFn( - teamId: number + teamId: number, + mapIds?: number[] ): Promise<{ name: string; mapCount: number }[]> { + if (mapIds && mapIds.length > 0) { + // Verify the maps belong to this team and get their IDs + const maps = await prisma.map.findMany({ + where: { + id: { in: mapIds }, + Scrim: { teamId }, + }, + select: { id: true }, + }); + + const validMapIds = maps.map((m) => m.id); + + if (validMapIds.length === 0) { + return []; + } + + // NOTE: PlayerStat.MapDataId actually stores Map.id, not MapData.id + // This is confusing but confirmed by runtime data + const allPlayerStats = await prisma.playerStat.findMany({ + where: { MapDataId: { in: validMapIds } }, + select: { + player_name: true, + MapDataId: true, + }, + }); + + // Count unique maps per player + const playerMapCounts = new Map>(); + + for (const stat of allPlayerStats) { + const mapId = stat.MapDataId; + if (!mapId) continue; + + const playerName = stat.player_name; + if (!playerMapCounts.has(playerName)) { + playerMapCounts.set(playerName, new Set()); + } + playerMapCounts.get(playerName)!.add(mapId); + } + + const players = Array.from(playerMapCounts.entries()) + .map(([name, mapSet]) => ({ name, mapCount: mapSet.size })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return players; + } + const scrims = await prisma.scrim.findMany({ where: { teamId }, select: { From b481e8e3135ce01518e3633523fb846c5123ce91 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:15:40 -0500 Subject: [PATCH 022/103] Add PlayerSelector component for selecting players with search functionality and team filtering --- src/components/compare/player-selector.tsx | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/components/compare/player-selector.tsx diff --git a/src/components/compare/player-selector.tsx b/src/components/compare/player-selector.tsx new file mode 100644 index 000000000..89313092f --- /dev/null +++ b/src/components/compare/player-selector.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Check, ChevronsUpDown, User } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type PlayerSelectorProps = { + teamId: number; + mapIds: number[]; + value: string | null; + onChange: (player: string | null) => void; +}; + +type Player = { + name: string; + id: number; + mapCount: number; +}; + +async function fetchTeamPlayers( + teamId: number, + mapIds: number[] +): Promise { + const params = new URLSearchParams({ + teamId: teamId.toString(), + }); + + if (mapIds.length > 0) { + params.set("mapIds", JSON.stringify(mapIds)); + } + + const response = await fetch(`/api/compare/players?${params.toString()}`); + if (!response.ok) { + throw new Error("Failed to fetch team players"); + } + const data = (await response.json()) as { players: Player[] }; + return data.players; +} + +export function PlayerSelector({ + teamId, + mapIds, + value, + onChange, +}: PlayerSelectorProps) { + const t = useTranslations("comparePage.playerSelector"); + const [open, setOpen] = useState(false); + + const { data: players, isLoading } = useQuery({ + queryKey: ["team-players", teamId, mapIds], + queryFn: () => fetchTeamPlayers(teamId, mapIds), + staleTime: 10 * 60 * 1000, + }); + + return ( + + + + + + + + + {isLoading ? t("loading") : t("noPlayerFound")} + + + {players?.map((player) => ( + { + onChange(player.name); + setOpen(false); + }} + > + +
+ {player.name} + + {t("mapCount", { count: player.mapCount })} + +
+
+ ))} +
+
+
+
+ ); +} From 4fc8c2a03a0b083b358ad7464810309de96b1c16 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:29:00 -0500 Subject: [PATCH 023/103] Enhance MapBreakdown type to include per-10 statistics and refactor comparison stats retrieval for improved clarity and accuracy --- src/data/comparison-dto.ts | 134 +++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 34 deletions(-) diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts index 00f174f18..26efc59b8 100644 --- a/src/data/comparison-dto.ts +++ b/src/data/comparison-dto.ts @@ -82,7 +82,13 @@ export type MapBreakdown = { date: Date; replayCode: string | null; heroes: HeroName[]; - stats: PlayerStat; + stats: PlayerStat & { + eliminationsPer10?: number; + deathsPer10?: number; + damagePer10?: number; + healingPer10?: number; + mitigatedPer10?: number; + }; calculatedStats: CalculatedStat[]; }; @@ -524,6 +530,8 @@ async function getComparisonStatsFn( throw new Error("No map data found for the provided map IDs"); } + // NOTE: PlayerStat.MapDataId actually stores Map.id, not MapData.id + // This is confusing but confirmed by getTeamPlayersFn const finalRoundStats = removeDuplicateRows( await prisma.$queryRaw` WITH maxTime AS ( @@ -533,7 +541,7 @@ async function getComparisonStatsFn( FROM "PlayerStat" WHERE - "MapDataId" IN (${Prisma.join(mapDataIds)}) + "MapDataId" IN (${Prisma.join(mapIds)}) GROUP BY "MapDataId" ) @@ -543,12 +551,13 @@ async function getComparisonStatsFn( "PlayerStat" ps INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" WHERE - ps."MapDataId" IN (${Prisma.join(mapDataIds)}) + ps."MapDataId" IN (${Prisma.join(mapIds)}) AND ps."player_name" ILIKE ${playerName} ${heroes && heroes.length > 0 ? Prisma.sql`AND ps."player_hero" IN (${Prisma.join(heroes)})` : Prisma.empty} ` ); + // CalculatedStat.MapDataId stores actual MapData.id (as per schema) const calculatedStatsWhere: Prisma.CalculatedStatWhereInput = { MapDataId: { in: mapDataIds }, playerName: { equals: playerName, mode: "insensitive" }, @@ -559,49 +568,106 @@ async function getComparisonStatsFn( where: calculatedStatsWhere, }); - const calculatedStatsByMapDataId: Record = {}; + // Map MapData IDs back to Map IDs for CalculatedStats + const mapDataIdToMapId = new Map(); + for (const map of maps) { + for (const mapData of map.mapData) { + mapDataIdToMapId.set(mapData.id, map.id); + } + } + + const calculatedStatsByMapId: Record = {}; calculatedStats.forEach((stat) => { - if (!calculatedStatsByMapDataId[stat.MapDataId]) { - calculatedStatsByMapDataId[stat.MapDataId] = []; + const mapId = mapDataIdToMapId.get(stat.MapDataId); + if (!mapId) return; + if (!calculatedStatsByMapId[mapId]) { + calculatedStatsByMapId[mapId] = []; } - calculatedStatsByMapDataId[stat.MapDataId].push(stat); + calculatedStatsByMapId[mapId].push(stat); }); - const statsByMapDataId: Record = {}; + // PlayerStat.MapDataId already contains Map.id, so use it directly + const statsByMapId: Record = {}; finalRoundStats.forEach((stat) => { - if (!statsByMapDataId[stat.MapDataId!]) { - statsByMapDataId[stat.MapDataId!] = []; + if (!stat.MapDataId) return; + const mapId = stat.MapDataId; // Already the Map ID + if (!statsByMapId[mapId]) { + statsByMapId[mapId] = []; } - statsByMapDataId[stat.MapDataId!].push(stat); + statsByMapId[mapId].push(stat); }); const perMapBreakdown: MapBreakdown[] = []; for (const map of maps) { - for (const mapData of map.mapData) { - const mapStats = statsByMapDataId[mapData.id] || []; - const mapCalcStats = calculatedStatsByMapDataId[mapData.id] || []; + const mapStats = statsByMapId[map.id] || []; + const mapCalcStats = calculatedStatsByMapId[map.id] || []; - if (mapStats.length === 0) continue; + if (mapStats.length === 0) continue; - const matchStart = mapData.match_start[0]; - const heroesPlayed = Array.from( - new Set(mapStats.map((s) => s.player_hero)) - ); - - perMapBreakdown.push({ - mapId: map.id, - mapDataId: mapData.id, - mapName: matchStart?.map_name || map.name, - mapType: matchStart?.map_type || ("Control" as MapType), - scrimId: map.scrimId ?? 0, - scrimName: map.Scrim?.name ?? "Unknown", - date: map.Scrim?.date ?? map.createdAt, - replayCode: map.replayCode, - heroes: heroesPlayed as HeroName[], - stats: mapStats[0], - calculatedStats: mapCalcStats, - }); - } + // Use the first mapData for metadata (map name, type, etc.) + const firstMapData = map.mapData[0]; + const matchStart = firstMapData?.match_start[0]; + const heroesPlayed = Array.from( + new Set(mapStats.map((s) => s.player_hero)) + ); + + // Aggregate stats across all heroes played on this map + const aggregatedMapStats = mapStats.reduce( + (acc, stat) => ({ + eliminations: acc.eliminations + stat.eliminations, + deaths: acc.deaths + stat.deaths, + all_damage_dealt: acc.all_damage_dealt + stat.all_damage_dealt, + healing_dealt: acc.healing_dealt + stat.healing_dealt, + damage_blocked: acc.damage_blocked + stat.damage_blocked, + hero_time_played: acc.hero_time_played + stat.hero_time_played, + }), + { + eliminations: 0, + deaths: 0, + all_damage_dealt: 0, + healing_dealt: 0, + damage_blocked: 0, + hero_time_played: 0, + } + ); + + // Calculate per-10 stats for this map + const timePlayed = aggregatedMapStats.hero_time_played || 0; + const statsWithPer10 = { + ...mapStats[0], // Use first stat for non-aggregated fields + ...aggregatedMapStats, + eliminationsPer10: calculatePer10( + aggregatedMapStats.eliminations, + timePlayed + ), + deathsPer10: calculatePer10(aggregatedMapStats.deaths, timePlayed), + damagePer10: calculatePer10( + aggregatedMapStats.all_damage_dealt, + timePlayed + ), + healingPer10: calculatePer10( + aggregatedMapStats.healing_dealt, + timePlayed + ), + mitigatedPer10: calculatePer10( + aggregatedMapStats.damage_blocked, + timePlayed + ), + }; + + perMapBreakdown.push({ + mapId: map.id, + mapDataId: firstMapData?.id ?? 0, + mapName: matchStart?.map_name || map.name, + mapType: matchStart?.map_type || ("Control" as MapType), + scrimId: map.scrimId ?? 0, + scrimName: map.Scrim?.name ?? "Unknown", + date: map.Scrim?.date ?? map.createdAt, + replayCode: map.replayCode, + heroes: heroesPlayed as HeroName[], + stats: statsWithPer10, + calculatedStats: mapCalcStats, + }); } perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); From e81c14c12b75c28ecfcf7a91115b7f89a1849250 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:29:08 -0500 Subject: [PATCH 024/103] Refactor ComparisonFilters to use Label component for improved accessibility and consistency in player and hero labels --- src/components/compare/comparison-filters.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/compare/comparison-filters.tsx b/src/components/compare/comparison-filters.tsx index 295660177..936c38eb0 100644 --- a/src/components/compare/comparison-filters.tsx +++ b/src/components/compare/comparison-filters.tsx @@ -4,6 +4,7 @@ import { HeroFilter } from "@/components/stats/player/hero-filter"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; import type { HeroName } from "@/types/heroes"; import { RotateCcw, X } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -57,13 +58,13 @@ export function ComparisonFilters({
-
+
{/* Player Selector */}
- - + Date: Tue, 27 Jan 2026 21:33:55 -0500 Subject: [PATCH 025/103] Refactor ChartsView to update statistical metrics naming and improve data handling for damage, healing, and mitigation statistics --- src/components/compare/charts-view.tsx | 69 ++++++++++++++------------ 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx index 1a569c8d8..1df912736 100644 --- a/src/components/compare/charts-view.tsx +++ b/src/components/compare/charts-view.tsx @@ -34,15 +34,15 @@ type ComparisonStats = { aggregated: { eliminations: number; deaths: number; - damage: number; - healing: number; - mitigated: number; + allDamageDealt: number; + healingDealt: number; + damageBlocked: number; heroTimePlayed: number; eliminationsPer10: number; deathsPer10: number; - damagePer10: number; - healingPer10: number; - mitigatedPer10: number; + allDamagePer10: number; + healingDealtPer10: number; + damageBlockedPer10: number; }; perMapBreakdown: { mapId: number; @@ -67,21 +67,21 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { fullName: map.mapName, elimsPer10: Number((map.stats.eliminationsPer10 ?? 0).toFixed(2)), deathsPer10: Number((map.stats.deathsPer10 ?? 0).toFixed(2)), - damagePer10: Number(((map.stats.damagePer10 ?? 0) / 1000).toFixed(2)), // Scale for better visualization + damagePer10: Number(((map.stats.allDamagePer10 ?? 0) / 1000).toFixed(2)), // Scale for better visualization })); const lineChartConfig: ChartConfig = { elimsPer10: { label: t("elimsPer10"), - color: "hsl(var(--chart-1))", + color: "var(--chart-1)", }, deathsPer10: { label: t("deathsPer10"), - color: "hsl(var(--chart-2))", + color: "var(--chart-2)", }, damagePer10: { label: t("damagePer10K"), - color: "hsl(var(--chart-3))", + color: "var(--chart-3)", }, }; @@ -101,18 +101,18 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { }, { stat: t("damage"), - map1: (stats.perMapBreakdown[0].stats.damage ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.damage ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.allDamageDealt ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.allDamageDealt ?? 0) / 1000, }, { stat: t("healing"), - map1: (stats.perMapBreakdown[0].stats.healing ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.healing ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.healingDealt ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.healingDealt ?? 0) / 1000, }, { stat: t("mitigated"), - map1: (stats.perMapBreakdown[0].stats.mitigated ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.mitigated ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.damageBlocked ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.damageBlocked ?? 0) / 1000, }, ] : []; @@ -122,11 +122,11 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { ? { map1: { label: stats.perMapBreakdown[0].mapName, - color: "hsl(var(--chart-1))", + color: "var(--chart-1)", }, map2: { label: stats.perMapBreakdown[1].mapName, - color: "hsl(var(--chart-2))", + color: "var(--chart-2)", }, } : {}; @@ -161,12 +161,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("damagePer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.damagePer10 ?? 0) / 1000 + (stats.perMapBreakdown[0].stats.allDamagePer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.damagePer10 ?? 0) / 1000 + (stats.perMapBreakdown[1].stats.allDamagePer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -174,12 +174,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("healingPer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.healingPer10 ?? 0) / 1000 + (stats.perMapBreakdown[0].stats.healingDealtPer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.healingPer10 ?? 0) / 1000 + (stats.perMapBreakdown[1].stats.healingDealtPer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -187,12 +187,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("mitigatedPer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.mitigatedPer10 ?? 0) / 1000 + (stats.perMapBreakdown[0].stats.damageBlockedPer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.mitigatedPer10 ?? 0) / 1000 + (stats.perMapBreakdown[1].stats.damageBlockedPer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -208,15 +208,19 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { }, { metric: t("damagePer10Short"), - value: Number((stats.aggregated.damagePer10 / 1000).toFixed(2)), + value: Number((stats.aggregated.allDamagePer10 / 1000).toFixed(2)), }, { metric: t("healingPer10Short"), - value: Number((stats.aggregated.healingPer10 / 1000).toFixed(2)), + value: Number( + (stats.aggregated.healingDealtPer10 / 1000).toFixed(2) + ), }, { metric: t("mitigatedPer10Short"), - value: Number((stats.aggregated.mitigatedPer10 / 1000).toFixed(2)), + value: Number( + (stats.aggregated.damageBlockedPer10 / 1000).toFixed(2) + ), }, ]; @@ -225,17 +229,17 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { ? { map1: { label: stats.perMapBreakdown[0].mapName, - color: "hsl(var(--chart-1))", + color: "var(--chart-1)", }, map2: { label: stats.perMapBreakdown[1].mapName, - color: "hsl(var(--chart-2))", + color: "var(--chart-2)", }, } : { value: { label: t("averagePerformance"), - color: "hsl(var(--chart-1))", + color: "var(--chart-1)", }, }; @@ -431,10 +435,11 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
- {stats.aggregated.damage.toLocaleString()} + {stats.aggregated.allDamageDealt.toLocaleString()}

- {t("avgPer10")}: {stats.aggregated.damagePer10.toLocaleString()} + {t("avgPer10")}:{" "} + {stats.aggregated.allDamagePer10.toLocaleString()}

From 26a557cff293706f0c110d737ea5b1b85d45a159 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:34:03 -0500 Subject: [PATCH 026/103] Refactor ComparisonStats type to update statistical metrics naming for damage, healing, and mitigation, enhancing clarity and consistency in data representation --- src/components/compare/comparison-content.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index 885d2c11e..6c69a0a3f 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -30,15 +30,15 @@ type ComparisonStats = { aggregated: { eliminations: number; deaths: number; - damage: number; - healing: number; - mitigated: number; + allDamageDealt: number; + healingDealt: number; + damageBlocked: number; heroTimePlayed: number; eliminationsPer10: number; deathsPer10: number; - damagePer10: number; - healingPer10: number; - mitigatedPer10: number; + allDamagePer10: number; + healingDealtPer10: number; + damageBlockedPer10: number; }; perMapBreakdown: { mapId: number; From 49e8d4048e18d210d11b801398172e119c5e6a11 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:36:47 -0500 Subject: [PATCH 027/103] Refactor MapBreakdown type to rename statistical metrics for damage, healing, and mitigation, ensuring consistency in data representation --- src/data/comparison-dto.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts index 26efc59b8..098e41f58 100644 --- a/src/data/comparison-dto.ts +++ b/src/data/comparison-dto.ts @@ -85,9 +85,9 @@ export type MapBreakdown = { stats: PlayerStat & { eliminationsPer10?: number; deathsPer10?: number; - damagePer10?: number; - healingPer10?: number; - mitigatedPer10?: number; + allDamagePer10?: number; + healingDealtPer10?: number; + damageBlockedPer10?: number; }; calculatedStats: CalculatedStat[]; }; @@ -641,15 +641,15 @@ async function getComparisonStatsFn( timePlayed ), deathsPer10: calculatePer10(aggregatedMapStats.deaths, timePlayed), - damagePer10: calculatePer10( + allDamagePer10: calculatePer10( aggregatedMapStats.all_damage_dealt, timePlayed ), - healingPer10: calculatePer10( + healingDealtPer10: calculatePer10( aggregatedMapStats.healing_dealt, timePlayed ), - mitigatedPer10: calculatePer10( + damageBlockedPer10: calculatePer10( aggregatedMapStats.damage_blocked, timePlayed ), From b77b3cf1c3503ffd4d56adc17c18b3e60ee63c5f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:36:53 -0500 Subject: [PATCH 028/103] Refactor comparison components to utilize updated ComparisonStats type, ensuring consistent naming for damage, healing, and mitigation metrics across charts, delta, side-by-side, and trends views --- src/components/compare/charts-view.tsx | 41 +++----------- src/components/compare/comparison-content.tsx | 35 +----------- src/components/compare/delta-view.tsx | 53 +++++-------------- src/components/compare/side-by-side-view.tsx | 53 +++++-------------- src/components/compare/trends-view.tsx | 43 ++------------- 5 files changed, 39 insertions(+), 186 deletions(-) diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx index 1df912736..3463c93cf 100644 --- a/src/components/compare/charts-view.tsx +++ b/src/components/compare/charts-view.tsx @@ -9,7 +9,7 @@ import { ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart"; -import type { HeroName } from "@/types/heroes"; +import type { ComparisonStats } from "@/data/comparison-dto"; import { useTranslations } from "next-intl"; import { Bar, @@ -26,33 +26,6 @@ import { YAxis, } from "recharts"; -type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: { - eliminations: number; - deaths: number; - allDamageDealt: number; - healingDealt: number; - damageBlocked: number; - heroTimePlayed: number; - eliminationsPer10: number; - deathsPer10: number; - allDamagePer10: number; - healingDealtPer10: number; - damageBlockedPer10: number; - }; - perMapBreakdown: { - mapId: number; - mapName: string; - date: Date; - heroes: HeroName[]; - stats: Record; - }[]; -}; - type ChartsViewProps = { stats: ComparisonStats; viewMode: "two-map" | "multi-map"; @@ -101,18 +74,18 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { }, { stat: t("damage"), - map1: (stats.perMapBreakdown[0].stats.allDamageDealt ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.allDamageDealt ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.all_damage_dealt ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.all_damage_dealt ?? 0) / 1000, }, { stat: t("healing"), - map1: (stats.perMapBreakdown[0].stats.healingDealt ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.healingDealt ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.healing_dealt ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.healing_dealt ?? 0) / 1000, }, { stat: t("mitigated"), - map1: (stats.perMapBreakdown[0].stats.damageBlocked ?? 0) / 1000, - map2: (stats.perMapBreakdown[1].stats.damageBlocked ?? 0) / 1000, + map1: (stats.perMapBreakdown[0].stats.damage_blocked ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.damage_blocked ?? 0) / 1000, }, ] : []; diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index 6c69a0a3f..b6eecf01e 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -2,6 +2,7 @@ import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { ComparisonStats } from "@/data/comparison-dto"; import type { HeroName } from "@/types/heroes"; import { useQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; @@ -22,40 +23,6 @@ type ComparisonContentProps = { type ViewMode = "side-by-side" | "delta" | "trends" | "charts"; -type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: { - eliminations: number; - deaths: number; - allDamageDealt: number; - healingDealt: number; - damageBlocked: number; - heroTimePlayed: number; - eliminationsPer10: number; - deathsPer10: number; - allDamagePer10: number; - healingDealtPer10: number; - damageBlockedPer10: number; - }; - perMapBreakdown: { - mapId: number; - mapName: string; - date: Date; - heroes: HeroName[]; - stats: Record; - }[]; - trends?: { - improvingMetrics: string[]; - decliningMetrics: string[]; - earlyPerformance?: Record; - latePerformance?: Record; - }; - heroBreakdown?: Record>; -}; - async function fetchComparisonStats( mapIds: number[], playerName: string, diff --git a/src/components/compare/delta-view.tsx b/src/components/compare/delta-view.tsx index 6d3aeaa9a..666661dcd 100644 --- a/src/components/compare/delta-view.tsx +++ b/src/components/compare/delta-view.tsx @@ -2,38 +2,11 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ComparisonStats } from "@/data/comparison-dto"; import { cn } from "@/lib/utils"; -import type { HeroName } from "@/types/heroes"; import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; -type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: { - eliminations: number; - deaths: number; - damage: number; - healing: number; - mitigated: number; - heroTimePlayed: number; - eliminationsPer10: number; - deathsPer10: number; - damagePer10: number; - healingPer10: number; - mitigatedPer10: number; - }; - perMapBreakdown: { - mapId: number; - mapName: string; - date: Date; - heroes: HeroName[]; - stats: Record; - }[]; -}; - type DeltaViewProps = { stats: ComparisonStats; }; @@ -112,20 +85,20 @@ export function DeltaView({ stats }: DeltaViewProps) { }, { label: t("stats.damage"), - oldValue: map1.stats.damage ?? 0, - newValue: map2.stats.damage ?? 0, + oldValue: map1.stats.all_damage_dealt ?? 0, + newValue: map2.stats.all_damage_dealt ?? 0, format: "number", }, { label: t("stats.healing"), - oldValue: map1.stats.healing ?? 0, - newValue: map2.stats.healing ?? 0, + oldValue: map1.stats.healing_dealt ?? 0, + newValue: map2.stats.healing_dealt ?? 0, format: "number", }, { label: t("stats.mitigated"), - oldValue: map1.stats.mitigated ?? 0, - newValue: map2.stats.mitigated ?? 0, + oldValue: map1.stats.damage_blocked ?? 0, + newValue: map2.stats.damage_blocked ?? 0, format: "number", }, { @@ -143,20 +116,20 @@ export function DeltaView({ stats }: DeltaViewProps) { }, { label: t("stats.damagePer10"), - oldValue: map1.stats.damagePer10 ?? 0, - newValue: map2.stats.damagePer10 ?? 0, + oldValue: map1.stats.allDamagePer10 ?? 0, + newValue: map2.stats.allDamagePer10 ?? 0, format: "per10", }, { label: t("stats.healingPer10"), - oldValue: map1.stats.healingPer10 ?? 0, - newValue: map2.stats.healingPer10 ?? 0, + oldValue: map1.stats.healingDealtPer10 ?? 0, + newValue: map2.stats.healingDealtPer10 ?? 0, format: "per10", }, { label: t("stats.mitigatedPer10"), - oldValue: map1.stats.mitigatedPer10 ?? 0, - newValue: map2.stats.mitigatedPer10 ?? 0, + oldValue: map1.stats.damageBlockedPer10 ?? 0, + newValue: map2.stats.damageBlockedPer10 ?? 0, format: "per10", }, ]; diff --git a/src/components/compare/side-by-side-view.tsx b/src/components/compare/side-by-side-view.tsx index a083ffb23..68ab0b3b7 100644 --- a/src/components/compare/side-by-side-view.tsx +++ b/src/components/compare/side-by-side-view.tsx @@ -2,37 +2,10 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { HeroName } from "@/types/heroes"; +import type { ComparisonStats } from "@/data/comparison-dto"; import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { useTranslations } from "next-intl"; -type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: { - eliminations: number; - deaths: number; - damage: number; - healing: number; - mitigated: number; - heroTimePlayed: number; - eliminationsPer10: number; - deathsPer10: number; - damagePer10: number; - healingPer10: number; - mitigatedPer10: number; - }; - perMapBreakdown: { - mapId: number; - mapName: string; - date: Date; - heroes: HeroName[]; - stats: Record; - }[]; -}; - type SideBySideViewProps = { stats: ComparisonStats; }; @@ -123,20 +96,20 @@ export function SideBySideView({ stats }: SideBySideViewProps) { }, { label: t("stats.damage"), - map1Value: map1.stats.damage ?? 0, - map2Value: map2.stats.damage ?? 0, + map1Value: map1.stats.all_damage_dealt ?? 0, + map2Value: map2.stats.all_damage_dealt ?? 0, format: "number", }, { label: t("stats.healing"), - map1Value: map1.stats.healing ?? 0, - map2Value: map2.stats.healing ?? 0, + map1Value: map1.stats.healing_dealt ?? 0, + map2Value: map2.stats.healing_dealt ?? 0, format: "number", }, { label: t("stats.mitigated"), - map1Value: map1.stats.mitigated ?? 0, - map2Value: map2.stats.mitigated ?? 0, + map1Value: map1.stats.damage_blocked ?? 0, + map2Value: map2.stats.damage_blocked ?? 0, format: "number", }, { @@ -154,20 +127,20 @@ export function SideBySideView({ stats }: SideBySideViewProps) { }, { label: t("stats.damagePer10"), - map1Value: map1.stats.damagePer10 ?? 0, - map2Value: map2.stats.damagePer10 ?? 0, + map1Value: map1.stats.allDamagePer10 ?? 0, + map2Value: map2.stats.allDamagePer10 ?? 0, format: "per10", }, { label: t("stats.healingPer10"), - map1Value: map1.stats.healingPer10 ?? 0, - map2Value: map2.stats.healingPer10 ?? 0, + map1Value: map1.stats.healingDealtPer10 ?? 0, + map2Value: map2.stats.healingDealtPer10 ?? 0, format: "per10", }, { label: t("stats.mitigatedPer10"), - map1Value: map1.stats.mitigatedPer10 ?? 0, - map2Value: map2.stats.mitigatedPer10 ?? 0, + map1Value: map1.stats.damageBlockedPer10 ?? 0, + map2Value: map2.stats.damageBlockedPer10 ?? 0, format: "per10", }, ]; diff --git a/src/components/compare/trends-view.tsx b/src/components/compare/trends-view.tsx index 5e8b1fcee..fbfabf607 100644 --- a/src/components/compare/trends-view.tsx +++ b/src/components/compare/trends-view.tsx @@ -2,8 +2,8 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ComparisonStats } from "@/data/comparison-dto"; import { cn } from "@/lib/utils"; -import type { HeroName } from "@/types/heroes"; import { AlertTriangle, Minus, @@ -13,39 +13,6 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; -type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: { - eliminations: number; - deaths: number; - damage: number; - healing: number; - mitigated: number; - heroTimePlayed: number; - eliminationsPer10: number; - deathsPer10: number; - damagePer10: number; - healingPer10: number; - mitigatedPer10: number; - }; - perMapBreakdown: { - mapId: number; - mapName: string; - date: Date; - heroes: HeroName[]; - stats: Record; - }[]; - trends?: { - improvingMetrics: string[]; - decliningMetrics: string[]; - earlyPerformance?: Record; - latePerformance?: Record; - }; -}; - type TrendsViewProps = { stats: ComparisonStats; }; @@ -86,7 +53,7 @@ export function TrendsView({ stats }: TrendsViewProps) { const avgDamagePer10 = stats.perMapBreakdown.reduce( - (sum, map) => sum + (map.stats.damagePer10 ?? 0), + (sum, map) => sum + (map.stats.allDamagePer10 ?? 0), 0 ) / stats.perMapBreakdown.length; @@ -298,7 +265,7 @@ export function TrendsView({ stats }: TrendsViewProps) { {t("damagePer10")}

- {(bestMap.stats.damagePer10 ?? 0).toLocaleString()} + {(bestMap.stats.allDamagePer10 ?? 0).toLocaleString()}

@@ -346,7 +313,7 @@ export function TrendsView({ stats }: TrendsViewProps) { {t("damagePer10")}

- {(worstMap.stats.damagePer10 ?? 0).toLocaleString()} + {(worstMap.stats.allDamagePer10 ?? 0).toLocaleString()}

@@ -400,7 +367,7 @@ export function TrendsView({ stats }: TrendsViewProps) { {t("damage")}

- {(map.stats.damagePer10 ?? 0).toLocaleString()} + {(map.stats.allDamagePer10 ?? 0).toLocaleString()}

From 795ad6c8aa3692d535c0a97354d4ea90a11e292e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 21:54:42 -0500 Subject: [PATCH 029/103] Fix typo --- messages/en.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/messages/en.json b/messages/en.json index 40b3a8a21..9adc455ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -895,7 +895,7 @@ "barrierDmgDealt": "Barrier Damage Dealt", "heroDmgDealt": "Hero Damage Dealt", "healingDealt": "Healing Dealt", - "healingReceived": "Healing Receieved", + "healingReceived": "Healing Received", "selfHealing": "Self Healing", "dmgTaken": "Damage Taken", "dmgBlocked": "Damage Blocked", @@ -1064,10 +1064,10 @@ "kd": "K/D", "kad": "KA/D", "heroDmgDealt": "Hero Damage Dealt", - "dmgReceived": "Damage Receieved", - "healingReceived": "Healing Receieved", + "dmgReceived": "Damage Received", + "healingReceived": "Healing Received", "healingDealt": "Healing Dealt", - "dmgToHealsRatio": "Damage Dealt:Healing Receieved", + "dmgToHealsRatio": "Damage Dealt:Healing Received", "ultsCharged": "Ultimates Charged", "ultsUsed": "Ultimates Used" }, @@ -1206,8 +1206,8 @@ "healingDealt": "Healing Dealt", "healingDealtNum": "{num} Healing Dealt", "healingDealtPer10Min": "{num} healing dealt per 10 minutes", - "healingReceived": "Healing Receieved", - "healingReceivedNum": "{num} Healing Receieved", + "healingReceived": "Healing Received", + "healingReceivedNum": "{num} Healing Received", "healingReceivedPer10Min": "{num} healing received per 10 minutes" }, "specificHero": { @@ -1242,8 +1242,8 @@ "healingDealt": "Healing Dealt", "healingDealtNum": "{num} Healing Dealt", "healingDealtPer10Min": "{num} healing dealt per 10 minutes", - "healingReceived": "Healing Receieved", - "healingReceivedNum": "{num} Healing Receieved", + "healingReceived": "Healing Received", + "healingReceivedNum": "{num} Healing Received", "healingReceivedPer10Min": "{num} healing received per 10 minutes", "ratingTooltip": "Your hero SR is calculated based on map performance. For overall hero SR, hover over your player name in the statistics table on the map overview page.", "unratedTooltip": "Play at least 60 seconds to be ranked", @@ -1525,7 +1525,7 @@ "eliminations": "Eliminations", "final_blows": "Final Blows", "healing_dealt": "Healing Dealt", - "healing_received": "Healing Receieved", + "healing_received": "Healing Received", "self_healing": "Self Healing", "damage_taken": "Damage Taken", "damage_blocked": "Damage Blocked", @@ -1646,7 +1646,7 @@ "eliminations": "Eliminations", "final_blows": "Final Blows", "healing_dealt": "Healing Dealt", - "healing_received": "Healing Receieved", + "healing_received": "Healing Received", "self_healing": "Self Healing", "damage_taken": "Damage Taken", "damage_blocked": "Damage Blocked", From eb0fb8a364a30441326a127d25ef4a3b2c5fd28f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 22:41:36 -0500 Subject: [PATCH 030/103] Update ChartsView to improve data representation by including scrim names in chart labels and enhancing null safety for statistical metrics --- src/components/compare/charts-view.tsx | 149 ++++++++++++++----------- 1 file changed, 82 insertions(+), 67 deletions(-) diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx index 3463c93cf..5261d6fc3 100644 --- a/src/components/compare/charts-view.tsx +++ b/src/components/compare/charts-view.tsx @@ -35,9 +35,9 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { const t = useTranslations("comparePage.charts"); // Prepare data for line chart (multi-map progression) - const lineChartData = stats.perMapBreakdown.map((map, index) => ({ - name: `Map ${index + 1}`, - fullName: map.mapName, + const lineChartData = stats.perMapBreakdown.map((map) => ({ + name: `${map.scrimName} - ${map.mapName}`, + fullName: `${map.scrimName} - ${map.mapName}`, elimsPer10: Number((map.stats.eliminationsPer10 ?? 0).toFixed(2)), deathsPer10: Number((map.stats.deathsPer10 ?? 0).toFixed(2)), damagePer10: Number(((map.stats.allDamagePer10 ?? 0) / 1000).toFixed(2)), // Scale for better visualization @@ -94,11 +94,11 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { viewMode === "two-map" && stats.perMapBreakdown.length === 2 ? { map1: { - label: stats.perMapBreakdown[0].mapName, + label: `${stats.perMapBreakdown[0].scrimName} - ${stats.perMapBreakdown[0].mapName}`, color: "var(--chart-1)", }, map2: { - label: stats.perMapBreakdown[1].mapName, + label: `${stats.perMapBreakdown[1].scrimName} - ${stats.perMapBreakdown[1].mapName}`, color: "var(--chart-2)", }, } @@ -106,26 +106,30 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { // Prepare data for radar chart (performance profile) const radarChartData = - viewMode === "two-map" && stats.perMapBreakdown.length === 2 + viewMode === "two-map" && stats.perMapBreakdown?.length === 2 ? [ { metric: t("elimsPer10Short"), map1: Number( - (stats.perMapBreakdown[0].stats.eliminationsPer10 ?? 0).toFixed(2) + (stats.perMapBreakdown[0]?.stats.eliminationsPer10 ?? 0).toFixed( + 2 + ) ), map2: Number( - (stats.perMapBreakdown[1].stats.eliminationsPer10 ?? 0).toFixed(2) + (stats.perMapBreakdown[1]?.stats.eliminationsPer10 ?? 0).toFixed( + 2 + ) ), }, { metric: t("deathsPer10Short"), map1: Number( - (20 - (stats.perMapBreakdown[0].stats.deathsPer10 ?? 0)).toFixed( + (20 - (stats.perMapBreakdown[0]?.stats.deathsPer10 ?? 0)).toFixed( 2 ) ), // Invert for better visualization map2: Number( - (20 - (stats.perMapBreakdown[1].stats.deathsPer10 ?? 0)).toFixed( + (20 - (stats.perMapBreakdown[1]?.stats.deathsPer10 ?? 0)).toFixed( 2 ) ), @@ -134,12 +138,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("damagePer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.allDamagePer10 ?? 0) / 1000 + (stats.perMapBreakdown[0]?.stats.allDamagePer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.allDamagePer10 ?? 0) / 1000 + (stats.perMapBreakdown[1]?.stats.allDamagePer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -147,12 +151,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("healingPer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.healingDealtPer10 ?? 0) / 1000 + (stats.perMapBreakdown[0]?.stats.healingDealtPer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.healingDealtPer10 ?? 0) / 1000 + (stats.perMapBreakdown[1]?.stats.healingDealtPer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -160,12 +164,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { metric: t("mitigatedPer10Short"), map1: Number( ( - (stats.perMapBreakdown[0].stats.damageBlockedPer10 ?? 0) / 1000 + (stats.perMapBreakdown[0]?.stats.damageBlockedPer10 ?? 0) / 1000 ).toFixed(2) ), map2: Number( ( - (stats.perMapBreakdown[1].stats.damageBlockedPer10 ?? 0) / 1000 + (stats.perMapBreakdown[1]?.stats.damageBlockedPer10 ?? 0) / 1000 ).toFixed(2) ), }, @@ -173,39 +177,45 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { : [ { metric: t("elimsPer10Short"), - value: Number(stats.aggregated.eliminationsPer10.toFixed(2)), + value: Number( + (stats.aggregated?.eliminationsPer10 ?? 0).toFixed(2) + ), }, { metric: t("deathsPer10Short"), - value: Number((20 - stats.aggregated.deathsPer10).toFixed(2)), // Invert + value: Number( + (20 - (stats.aggregated?.deathsPer10 ?? 0)).toFixed(2) + ), // Invert }, { metric: t("damagePer10Short"), - value: Number((stats.aggregated.allDamagePer10 / 1000).toFixed(2)), + value: Number( + ((stats.aggregated?.allDamagePer10 ?? 0) / 1000).toFixed(2) + ), }, { metric: t("healingPer10Short"), value: Number( - (stats.aggregated.healingDealtPer10 / 1000).toFixed(2) + ((stats.aggregated?.healingDealtPer10 ?? 0) / 1000).toFixed(2) ), }, { metric: t("mitigatedPer10Short"), value: Number( - (stats.aggregated.damageBlockedPer10 / 1000).toFixed(2) + ((stats.aggregated?.damageBlockedPer10 ?? 0) / 1000).toFixed(2) ), }, ]; const radarChartConfig: ChartConfig = - viewMode === "two-map" && stats.perMapBreakdown.length === 2 + viewMode === "two-map" && stats.perMapBreakdown?.length === 2 ? { map1: { - label: stats.perMapBreakdown[0].mapName, + label: `${stats.perMapBreakdown[0].scrimName} - ${stats.perMapBreakdown[0].mapName}`, color: "var(--chart-1)", }, map2: { - label: stats.perMapBreakdown[1].mapName, + label: `${stats.perMapBreakdown[1].scrimName} - ${stats.perMapBreakdown[1].mapName}`, color: "var(--chart-2)", }, } @@ -320,51 +330,56 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) { )} {/* Radar Chart - Performance Profile */} - - - {t("performanceProfile")} - - - - - - - - } /> - {viewMode === "two-map" && stats.perMapBreakdown.length === 2 ? ( - <> - + {radarChartData && radarChartData.length > 0 ? ( + + + {t("performanceProfile")} + + + + + } + /> + + + + {viewMode === "two-map" && + stats.perMapBreakdown?.length === 2 ? ( + <> + + + + ) : ( - } /> - - ) : ( - - )} - - - - + )} + + + + + ) : null} {/* Additional Stats Cards */}
From 4d3c70cec585fe6a6267ffe0dbff66630b78b6bf Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 23:11:06 -0500 Subject: [PATCH 031/103] Enhance player statistics aggregation by introducing variance metrics --- src/data/comparison-dto.ts | 137 ++++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts index 098e41f58..a7ff8eecc 100644 --- a/src/data/comparison-dto.ts +++ b/src/data/comparison-dto.ts @@ -1,5 +1,9 @@ import "server-only"; +import { + calculateMean, + calculateStandardDeviation, +} from "@/lib/distribution-utils"; import prisma from "@/lib/prisma"; import { removeDuplicateRows } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; @@ -70,6 +74,12 @@ export type AggregatedStats = { killsPerUltimate: number; duelWinratePercentage: number; fightReversalPercentage: number; + eliminationsPer10StdDev: number; + deathsPer10StdDev: number; + allDamagePer10StdDev: number; + healingDealtPer10StdDev: number; + firstPickPercentageStdDev: number; + consistencyScore: number; }; export type MapBreakdown = { @@ -258,9 +268,114 @@ function aggregateCalculatedStats( return result; } +function calculateVarianceMetrics( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] +): { + eliminationsPer10StdDev: number; + deathsPer10StdDev: number; + allDamagePer10StdDev: number; + healingDealtPer10StdDev: number; + firstPickPercentageStdDev: number; + consistencyScore: number; +} { + if (perMapStats.length < 2) { + return { + eliminationsPer10StdDev: 0, + deathsPer10StdDev: 0, + allDamagePer10StdDev: 0, + healingDealtPer10StdDev: 0, + firstPickPercentageStdDev: 0, + consistencyScore: 0, + }; + } + + const eliminationsPer10Values = perMapStats.map((stat) => + calculatePer10(stat.eliminations, stat.hero_time_played) + ); + const deathsPer10Values = perMapStats.map((stat) => + calculatePer10(stat.deaths, stat.hero_time_played) + ); + const allDamagePer10Values = perMapStats.map((stat) => + calculatePer10(stat.all_damage_dealt, stat.hero_time_played) + ); + const healingDealtPer10Values = perMapStats.map((stat) => + calculatePer10(stat.healing_dealt, stat.hero_time_played) + ); + + const firstPickPercentageValues = perMapCalculatedStats + .map((stats) => { + const firstPickStat = stats.find( + (s) => s.stat === CalculatedStatType.FIRST_PICK_PERCENTAGE + ); + return firstPickStat?.value ?? 0; + }) + .filter((v) => v > 0); + + const eliminationsPer10Mean = calculateMean(eliminationsPer10Values); + const deathsPer10Mean = calculateMean(deathsPer10Values); + const allDamagePer10Mean = calculateMean(allDamagePer10Values); + const healingDealtPer10Mean = calculateMean(healingDealtPer10Values); + const firstPickPercentageMean = calculateMean(firstPickPercentageValues); + + const eliminationsPer10StdDev = calculateStandardDeviation( + eliminationsPer10Values, + eliminationsPer10Mean + ); + const deathsPer10StdDev = calculateStandardDeviation( + deathsPer10Values, + deathsPer10Mean + ); + const allDamagePer10StdDev = calculateStandardDeviation( + allDamagePer10Values, + allDamagePer10Mean + ); + const healingDealtPer10StdDev = calculateStandardDeviation( + healingDealtPer10Values, + healingDealtPer10Mean + ); + const firstPickPercentageStdDev = + firstPickPercentageValues.length > 1 + ? calculateStandardDeviation( + firstPickPercentageValues, + firstPickPercentageMean + ) + : 0; + + const coefficientOfVariations = [ + eliminationsPer10Mean > 0 + ? eliminationsPer10StdDev / eliminationsPer10Mean + : 0, + deathsPer10Mean > 0 ? deathsPer10StdDev / deathsPer10Mean : 0, + allDamagePer10Mean > 0 ? allDamagePer10StdDev / allDamagePer10Mean : 0, + healingDealtPer10Mean > 0 + ? healingDealtPer10StdDev / healingDealtPer10Mean + : 0, + ].filter((cv) => cv > 0); + + const averageCV = + coefficientOfVariations.length > 0 + ? calculateMean(coefficientOfVariations) + : 0; + + const consistencyScore = + averageCV > 0 ? Math.max(0, Math.min(100, (1 - averageCV) * 100)) : 0; + + return { + eliminationsPer10StdDev, + deathsPer10StdDev, + allDamagePer10StdDev, + healingDealtPer10StdDev, + firstPickPercentageStdDev, + consistencyScore, + }; +} + function aggregatePlayerStats( stats: PlayerStat[], - calculatedStats: CalculatedStat[] + calculatedStats: CalculatedStat[], + perMapStats?: PlayerStat[], + perMapCalculatedStats?: CalculatedStat[][] ): AggregatedStats { const totals = stats.reduce( (acc, stat) => { @@ -330,6 +445,18 @@ function aggregatePlayerStats( const calculatedAggregates = aggregateCalculatedStats(calculatedStats); + const varianceMetrics = + perMapStats && perMapCalculatedStats + ? calculateVarianceMetrics(perMapStats, perMapCalculatedStats) + : { + eliminationsPer10StdDev: 0, + deathsPer10StdDev: 0, + allDamagePer10StdDev: 0, + healingDealtPer10StdDev: 0, + firstPickPercentageStdDev: 0, + consistencyScore: 0, + }; + return { ...totals, eliminationsPer10: calculatePer10( @@ -390,6 +517,7 @@ function aggregatePlayerStats( killsPerUltimate: calculatedAggregates.killsPerUltimate ?? 0, duelWinratePercentage: calculatedAggregates.duelWinratePercentage ?? 0, fightReversalPercentage: calculatedAggregates.fightReversalPercentage ?? 0, + ...varianceMetrics, }; } @@ -672,7 +800,12 @@ async function getComparisonStatsFn( perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); - const aggregated = aggregatePlayerStats(finalRoundStats, calculatedStats); + const aggregated = aggregatePlayerStats( + finalRoundStats, + calculatedStats, + perMapBreakdown.map((m) => m.stats), + perMapBreakdown.map((m) => m.calculatedStats) + ); const trends = perMapBreakdown.length >= 3 From 36222073f4dd799cca0653f893a5949cb7e19af8 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 23:43:55 -0500 Subject: [PATCH 032/103] Add ConsistencyView component to visualize player performance consistency metrics --- src/components/compare/consistency-view.tsx | 494 ++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 src/components/compare/consistency-view.tsx diff --git a/src/components/compare/consistency-view.tsx b/src/components/compare/consistency-view.tsx new file mode 100644 index 000000000..7fd7eb5cd --- /dev/null +++ b/src/components/compare/consistency-view.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ComparisonStats } from "@/data/comparison-dto"; +import { Activity, Target, TrendingUp, Zap } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ComposedChart, + ErrorBar, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type ConsistencyViewProps = { + stats: ComparisonStats; +}; + +function getConsistencyLevel(score: number): { + label: string; + color: string; + icon: typeof Activity; + description: string; +} { + if (score >= 85) { + return { + label: "Highly Consistent", + color: "text-emerald-600 dark:text-emerald-400", + icon: Target, + description: "Rock solid performance across all maps", + }; + } + if (score >= 70) { + return { + label: "Consistent", + color: "text-green-600 dark:text-green-400", + icon: TrendingUp, + description: "Reliable performance with minor variance", + }; + } + if (score >= 55) { + return { + label: "Moderately Consistent", + color: "text-amber-600 dark:text-amber-400", + icon: Activity, + description: "Performance varies based on context", + }; + } + return { + label: "Variable Performance", + color: "text-rose-600 dark:text-rose-400", + icon: Zap, + description: "Significant variation across different maps", + }; +} + +export function ConsistencyView({ stats }: ConsistencyViewProps) { + const { aggregated, perMapBreakdown } = stats; + + const consistency = getConsistencyLevel(aggregated.consistencyScore); + const ConsistencyIcon = consistency.icon; + + const varianceData = [ + { + metric: "Eliminations", + mean: Number(aggregated.eliminationsPer10.toFixed(1)), + stdDev: Number(aggregated.eliminationsPer10StdDev.toFixed(1)), + lower: Number( + Math.max( + 0, + aggregated.eliminationsPer10 - aggregated.eliminationsPer10StdDev + ).toFixed(1) + ), + upper: Number( + ( + aggregated.eliminationsPer10 + aggregated.eliminationsPer10StdDev + ).toFixed(1) + ), + cv: aggregated.eliminationsPer10 + ? ( + (aggregated.eliminationsPer10StdDev / + aggregated.eliminationsPer10) * + 100 + ).toFixed(1) + : "0.0", + }, + { + metric: "Deaths", + mean: Number(aggregated.deathsPer10.toFixed(1)), + stdDev: Number(aggregated.deathsPer10StdDev.toFixed(1)), + lower: Number( + Math.max( + 0, + aggregated.deathsPer10 - aggregated.deathsPer10StdDev + ).toFixed(1) + ), + upper: Number( + (aggregated.deathsPer10 + aggregated.deathsPer10StdDev).toFixed(1) + ), + cv: aggregated.deathsPer10 + ? ( + (aggregated.deathsPer10StdDev / aggregated.deathsPer10) * + 100 + ).toFixed(1) + : "0.0", + }, + { + metric: "Damage", + mean: Number((aggregated.allDamagePer10 / 1000).toFixed(1)), + stdDev: Number((aggregated.allDamagePer10StdDev / 1000).toFixed(1)), + lower: Number( + Math.max( + 0, + (aggregated.allDamagePer10 - aggregated.allDamagePer10StdDev) / 1000 + ).toFixed(1) + ), + upper: Number( + ( + (aggregated.allDamagePer10 + aggregated.allDamagePer10StdDev) / + 1000 + ).toFixed(1) + ), + cv: aggregated.allDamagePer10 + ? ( + (aggregated.allDamagePer10StdDev / aggregated.allDamagePer10) * + 100 + ).toFixed(1) + : "0.0", + }, + { + metric: "Healing", + mean: Number((aggregated.healingDealtPer10 / 1000).toFixed(1)), + stdDev: Number((aggregated.healingDealtPer10StdDev / 1000).toFixed(1)), + lower: Number( + Math.max( + 0, + (aggregated.healingDealtPer10 - aggregated.healingDealtPer10StdDev) / + 1000 + ).toFixed(1) + ), + upper: Number( + ( + (aggregated.healingDealtPer10 + aggregated.healingDealtPer10StdDev) / + 1000 + ).toFixed(1) + ), + cv: aggregated.healingDealtPer10 + ? ( + (aggregated.healingDealtPer10StdDev / + aggregated.healingDealtPer10) * + 100 + ).toFixed(1) + : "0.0", + }, + ].filter((d) => d.mean > 0); + + // Calculate the range width for the bar chart (this is what we'll display) + const varianceChartData = varianceData.map((item) => ({ + metric: item.metric, + mean: item.mean, + stdDev: item.stdDev, + lower: item.lower, + upper: item.upper, + cv: item.cv, + // For stacked bars: start at lower bound, then show the range (2 * stdDev) + rangeWidth: Number((item.stdDev * 2).toFixed(1)), + // Error bar format: [lowerError, upperError] as array + errorBar: [item.stdDev, item.stdDev], + })); + + const perMapData = perMapBreakdown.map((map, idx) => ({ + name: `Map ${idx + 1}`, + fullName: `${map.scrimName} - ${map.mapName}`, + elimsPer10: Number((map.stats.eliminationsPer10 ?? 0).toFixed(1)), + deathsPer10: Number((map.stats.deathsPer10 ?? 0).toFixed(1)), + damagePer10: Number(((map.stats.allDamagePer10 ?? 0) / 1000).toFixed(1)), + healingPer10: Number( + ((map.stats.healing_dealt / map.stats.hero_time_played) * 600) / 1000 + ).toFixed(1), + })); + + return ( +
+ +
+ + +
+
+ +
= 85 + ? "from-emerald-500/10 to-green-500/10" + : aggregated.consistencyScore >= 70 + ? "from-green-500/10 to-lime-500/10" + : aggregated.consistencyScore >= 55 + ? "from-amber-500/10 to-yellow-500/10" + : "from-rose-500/10 to-orange-500/10" + }`} + > + +
+ Performance Consistency +
+

+ Measures how reliable performance is across different maps and + contexts. Lower variance indicates consistent, dependable play. +

+
+ +
+
+ + Consistency Score + +
+ {aggregated.consistencyScore.toFixed(0)} +
+
+ + {consistency.label} + +
+
+
+
+ +
+

+ {consistency.description} +

+
+
+ + +
+

+
+ Mean ± Standard Deviation +

+ + {/* Vertical bar chart with error bars */} +
+ + + + + + { + if (!active || !payload?.[0]) return null; + const data = payload[0] + .payload as (typeof varianceChartData)[0]; + return ( +
+
+
+ {data.metric} per 10 +
+
+
+ + Mean: + + + {data.mean.toFixed(1)} + +
+
+ + Std Dev: + + + ±{data.stdDev.toFixed(1)} + +
+
+ + Range: + + + {data.lower.toFixed(1)} –{" "} + {data.upper.toFixed(1)} + +
+
+
+ + CV: + + {data.cv}% +
+
+
+
+
+ ); + }} + /> + + {varianceChartData.map((entry) => ( + + ))} + + +
+
+
+
+ +
+ {varianceData.map((item) => ( + + + + {item.metric} per 10 + + + +
+ + {item.mean} + + + ± {item.stdDev} + +
+
+
+ Range: {item.lower} - {item.upper} +
+
Variation: {item.cv}%
+
+
+
+
+
+
+ + + ))} +
+ + {perMapBreakdown.length >= 3 && ( +
+

+
+ Performance Across Maps +

+ +
+ + + + + + { + if (!active || !payload?.[0]) return null; + const data = payload[0] + .payload as (typeof perMapData)[0]; + return ( +
+
+
+ {data.fullName} +
+ {payload.map((entry) => ( +
+ {entry.name}: {entry.value} +
+ ))} +
+
+ ); + }} + /> + + + +
+
+
+
+ )} + +
+

+ Understanding Consistency Metrics +

+
+

+ Standard Deviation:{" "} + Measures how spread out performance is. Lower values = more + consistent. +

+

+ + Coefficient of Variation: + {" "} + Normalized measure of dispersion. Allows comparison across + different scales. +

+

+ Consistency Score:{" "} + Overall reliability metric (0-100). Higher scores indicate + dependable, steady performance. +

+
+
+
+
+
+ ); +} From 460496e4fea080eed83748cc7529ef3c5340a4f6 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 23:44:12 -0500 Subject: [PATCH 033/103] Update ComparisonContent to include ConsistencyView in available views and tabs, enhancing player performance analysis options --- src/components/compare/comparison-content.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index b6eecf01e..c81143b34 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -11,6 +11,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { ChartsView } from "./charts-view"; import { ComparisonFilters } from "./comparison-filters"; +import { ConsistencyView } from "./consistency-view"; import { DeltaView } from "./delta-view"; import { EmptyState } from "./empty-state"; import { SideBySideView } from "./side-by-side-view"; @@ -21,7 +22,7 @@ type ComparisonContentProps = { locale: string; }; -type ViewMode = "side-by-side" | "delta" | "trends" | "charts"; +type ViewMode = "side-by-side" | "delta" | "trends" | "charts" | "consistency"; async function fetchComparisonStats( mapIds: number[], @@ -87,9 +88,9 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) { // Determine available views based on map count const availableViews: ViewMode[] = useMemo(() => { return selectedMapIds.length === 2 - ? ["side-by-side", "delta", "charts"] + ? ["side-by-side", "delta", "charts", "consistency"] : selectedMapIds.length >= 3 - ? ["trends", "charts"] + ? ["trends", "charts", "consistency"] : []; }, [selectedMapIds.length]); @@ -167,7 +168,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) { value={activeView} onValueChange={(v) => setActiveView(v as ViewMode)} > - + {availableViews.includes("side-by-side") && ( {t("views.sideBySide")} @@ -182,6 +183,11 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) { {availableViews.includes("charts") && ( {t("views.charts")} )} + {availableViews.includes("consistency") && ( + + {t("views.consistency")} + + )} {availableViews.includes("side-by-side") && ( @@ -210,6 +216,12 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) { /> )} + + {availableViews.includes("consistency") && ( + + + + )} )}
From 2b6b8055ec3563a827358ce1ca367ddbafe29e5f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 23:56:39 -0500 Subject: [PATCH 034/103] Add METRIC_LABELS for improved metric labeling in ConsistencyView --- messages/en.json | 30 ++++++++++++- src/components/compare/consistency-view.tsx | 49 ++++++++++++++++----- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/messages/en.json b/messages/en.json index 9adc455ad..52e4c828d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -730,7 +730,8 @@ "sideBySide": "Side by Side", "delta": "Delta", "trends": "Trends", - "charts": "Charts" + "charts": "Charts", + "consistency": "Consistency" }, "filters": { "title": "Comparison Filters", @@ -833,6 +834,33 @@ "healingPer10Short": "Healing", "mitigatedPer10Short": "Mitigated", "averagePerformance": "Average Performance" + }, + "consistency": { + "title": "Performance Consistency", + "subtitle": "Measures how reliable performance is across different maps and contexts", + "consistencyScore": "Consistency Score", + "highlyConsistent": "Highly Consistent", + "consistent": "Consistent", + "moderatelyConsistent": "Moderately Consistent", + "variablePerformance": "Variable Performance", + "meanStdDev": "Mean ± Standard Deviation", + "performanceAcrossMaps": "Performance Across Maps", + "understanding": { + "title": "Understanding Consistency Metrics", + "stdDev": "Measures how spread out performance is. Lower values = more consistent.", + "cv": "Normalized measure of dispersion. Allows comparison across different scales.", + "score": "Overall reliability metric (0-100). Higher scores indicate dependable, steady performance." + }, + "metrics": { + "eliminations": "Eliminations", + "deaths": "Deaths", + "damage": "Damage", + "healing": "Healing", + "mean": "Mean", + "stdDev": "± Std Dev", + "range": "Range", + "variation": "Variation" + } } }, "mapPage": { diff --git a/src/components/compare/consistency-view.tsx b/src/components/compare/consistency-view.tsx index 7fd7eb5cd..cc450e473 100644 --- a/src/components/compare/consistency-view.tsx +++ b/src/components/compare/consistency-view.tsx @@ -58,6 +58,13 @@ function getConsistencyLevel(score: number): { }; } +const METRIC_LABELS: Record = { + elimsPer10: "Eliminations", + deathsPer10: "Deaths", + damagePer10: "Damage (k)", + healingPer10: "Healing (k)", +}; + export function ConsistencyView({ stats }: ConsistencyViewProps) { const { aggregated, perMapBreakdown } = stats; @@ -424,19 +431,39 @@ export function ConsistencyView({ stats }: ConsistencyViewProps) { const data = payload[0] .payload as (typeof perMapData)[0]; return ( -
-
-
+
+
+
{data.fullName}
- {payload.map((entry) => ( -
- {entry.name}: {entry.value} -
- ))} +
+ {payload.map((entry) => { + const label = + METRIC_LABELS[entry.dataKey as string] || + entry.name; + return ( +
+
+
+ + {label}: + +
+ + {entry.value} + +
+ ); + })} +
); From ac4f3180a3bb96ea3bdc1fd24496674b76568b53 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 27 Jan 2026 23:59:50 -0500 Subject: [PATCH 035/103] Add MapGroup model and migration for managing map groups associated with teams --- .../migration.sql | 26 +++++++++++++++++++ prisma/schema.prisma | 19 ++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 prisma/migrations/20260128045919_add_map_groups/migration.sql diff --git a/prisma/migrations/20260128045919_add_map_groups/migration.sql b/prisma/migrations/20260128045919_add_map_groups/migration.sql new file mode 100644 index 000000000..90150e353 --- /dev/null +++ b/prisma/migrations/20260128045919_add_map_groups/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "public"."MapGroup" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "teamId" INTEGER NOT NULL, + "mapIds" INTEGER[], + "category" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MapGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MapGroup_teamId_idx" ON "public"."MapGroup"("teamId"); + +-- CreateIndex +CREATE INDEX "MapGroup_createdBy_idx" ON "public"."MapGroup"("createdBy"); + +-- AddForeignKey +ALTER TABLE "public"."MapGroup" ADD CONSTRAINT "MapGroup_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MapGroup" ADD CONSTRAINT "MapGroup_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c124d77c8..620cfbb8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model User { appliedTitles AppliedTitle[] seenOnboarding Boolean @default(false) comparisonGroups ComparisonGroup[] + mapGroups MapGroup[] @@index([id, email]) } @@ -195,6 +196,7 @@ model Team { managers TeamManager[] readonly Boolean @default(false) comparisonGroups ComparisonGroup[] + mapGroups MapGroup[] } model TeamManager { @@ -241,6 +243,23 @@ model ComparisonGroup { @@index([teamId, playerName]) } +model MapGroup { + id Int @id @default(autoincrement()) + name String + description String? + teamId Int + mapIds Int[] + category String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@index([teamId]) + @@index([createdBy]) +} + model Scrim { id Int @id @default(autoincrement()) name String From 19eda632d5888b6cd6f3c990b53c159ede5a51e7 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 28 Jan 2026 00:06:30 -0500 Subject: [PATCH 036/103] Add MapGroupManager component for managing and organizing map groups, including creation, editing, and deletion functionalities --- src/components/compare/map-group-manager.tsx | 512 +++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 src/components/compare/map-group-manager.tsx diff --git a/src/components/compare/map-group-manager.tsx b/src/components/compare/map-group-manager.tsx new file mode 100644 index 000000000..fefe51c49 --- /dev/null +++ b/src/components/compare/map-group-manager.tsx @@ -0,0 +1,512 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { FormattedMapGroup } from "@/types/map-group"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + FolderPlus, + Loader2, + MoreVertical, + Pencil, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +type MapGroupManagerProps = { + teamId: number; + availableMaps: { + id: number; + name: string; + scrimName: string; + }[]; +}; + +type MapGroupFormData = { + name: string; + description: string; + category: string; + mapIds: number[]; +}; + +async function fetchMapGroups(teamId: number): Promise { + const response = await fetch(`/api/compare/map-groups?teamId=${teamId}`); + if (!response.ok) { + throw new Error("Failed to fetch map groups"); + } + const data = (await response.json()) as { + success: boolean; + groups: FormattedMapGroup[]; + }; + return data.groups; +} + +async function createMapGroup( + teamId: number, + formData: MapGroupFormData +): Promise { + const response = await fetch("/api/compare/map-groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...formData, + teamId, + }), + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to create map group"); + } + const data = (await response.json()) as { + success: boolean; + group: FormattedMapGroup; + }; + return data.group; +} + +async function updateMapGroup( + groupId: number, + formData: Partial +): Promise { + const response = await fetch(`/api/compare/map-groups/${groupId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to update map group"); + } + const data = (await response.json()) as { + success: boolean; + group: FormattedMapGroup; + }; + return data.group; +} + +async function deleteMapGroup(groupId: number): Promise { + const response = await fetch(`/api/compare/map-groups/${groupId}`, { + method: "DELETE", + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to delete map group"); + } +} + +function MapGroupForm({ + teamId, + availableMaps, + onSuccess, + editGroup, +}: { + teamId: number; + availableMaps: MapGroupManagerProps["availableMaps"]; + onSuccess: () => void; + editGroup?: FormattedMapGroup; +}) { + const queryClient = useQueryClient(); + + const [formData, setFormData] = useState({ + name: editGroup?.name ?? "", + description: editGroup?.description ?? "", + category: editGroup?.category ?? "", + mapIds: editGroup?.mapIds ?? [], + }); + + const createMutation = useMutation({ + mutationFn: () => createMapGroup(teamId, formData), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["mapGroups", teamId] }); + toast.success("Map group created", { + description: "Your map group has been created successfully.", + }); + onSuccess(); + }, + onError: (error: Error) => { + toast.error("Error", { + description: error.message, + }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: () => updateMapGroup(editGroup!.id, formData), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["mapGroups", teamId] }); + toast.success("Map group updated", { + description: "Your map group has been updated successfully.", + }); + onSuccess(); + }, + onError: (error: Error) => { + toast.error("Error", { + description: error.message, + }); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (editGroup) { + updateMutation.mutate(); + } else { + createMutation.mutate(); + } + } + + function toggleMapSelection(mapId: number) { + setFormData((prev) => ({ + ...prev, + mapIds: prev.mapIds.includes(mapId) + ? prev.mapIds.filter((id) => id !== mapId) + : [...prev.mapIds, mapId], + })); + } + + const isLoading = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + required + disabled={isLoading} + /> +
+ +
+ +