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}
+
+
+
+
+
+
+
+
+ {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("edit")}
+
-
-
- {map.replayCode && (
-
- )}
-
-
-
-
- ))}
- {hasPerms &&
}
+ )}
+
+
+
+
+
+
+
+
+ {/* Maps Section */}
+
+
+
+ {t("maps.title")}
+
- ) : (
- <>
-
-
- {t("noMaps.title")}
-
- {t("noMaps.description")}
-
- {t("noMaps.link")}
-
- .
-
-
-
- >
- )}
+
+ {maps.length > 0 ? (
+
+ {maps.map((map) => (
+
+ ))}
+ {hasPerms &&
}
+
+ ) : (
+ <>
+
+
+ {t("noMaps.title")}
+
+ {t("noMaps.description")}
+
+ {t("noMaps.link")}
+
+ .
+
+
+
+ >
+ )}
+
+
+ {/* 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 */}
-
- {t("heroesLabel")}
+
+ {t("heroesLabel")}
+
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 (
+
+ );
+ })}
+
);
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 (
+
+ );
+}
+
+export function MapGroupManager({
+ teamId,
+ availableMaps,
+}: MapGroupManagerProps) {
+ const queryClient = useQueryClient();
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
+ const [editingGroup, setEditingGroup] = useState(
+ null
+ );
+ const [deletingGroup, setDeletingGroup] = useState(
+ null
+ );
+
+ const { data: mapGroups, isLoading } = useQuery({
+ queryKey: ["mapGroups", teamId],
+ queryFn: () => fetchMapGroups(teamId),
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (groupId: number) => deleteMapGroup(groupId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["mapGroups", teamId] });
+ toast.success("Map group deleted", {
+ description: "The map group has been deleted successfully.",
+ });
+ setDeletingGroup(null);
+ },
+ onError: (error: Error) => {
+ toast.error("Error", {
+ description: error.message,
+ });
+ },
+ });
+
+ return (
+
+
+
+
+
Map Groups
+
+ Create custom map groups to organize and compare performance
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : !mapGroups || mapGroups.length === 0 ? (
+
+
+
+
+
No map groups yet
+
+ Create your first map group to organize maps and compare
+ performance across different contexts
+
+
+
+ ) : (
+
+ {mapGroups.map((group) => (
+
+
+
+
+
+ {group.name}
+
+ {group.category && (
+
+ {group.category}
+
+ )}
+
+
+
+
+
+
+ setEditingGroup(group)}
+ >
+
+ Edit
+
+ setDeletingGroup(group)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+
+
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+ Maps:
+
+ {group.mapCount}
+
+
+
+ Created by {group.createdBy}
+
+
+
+ ))}
+
+ )}
+
+ {/* Edit Dialog */}
+
+
+ {/* Delete Confirmation */}
+ setDeletingGroup(null)}
+ >
+
+
+ Delete Map Group
+
+ Are you sure you want to delete "{deletingGroup?.name}
+ "? This action cannot be undone.
+
+
+
+ Cancel
+ {
+ if (deletingGroup) {
+ deleteMutation.mutate(deletingGroup.id);
+ }
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {deleteMutation.isPending && (
+
+ )}
+ Delete
+
+
+
+
+
+
+ );
+}
From cad12a0e6b54df394f435b8881a9d46fb312b5f8 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:06:39 -0500
Subject: [PATCH 037/103] Add MapGroupSelector component for selecting and
managing map groups with multi-select functionality and category grouping
---
src/components/compare/map-group-selector.tsx | 222 ++++++++++++++++++
1 file changed, 222 insertions(+)
create mode 100644 src/components/compare/map-group-selector.tsx
diff --git a/src/components/compare/map-group-selector.tsx b/src/components/compare/map-group-selector.tsx
new file mode 100644
index 000000000..91fe2cab5
--- /dev/null
+++ b/src/components/compare/map-group-selector.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import type { FormattedMapGroup } from "@/types/map-group";
+import { useQuery } from "@tanstack/react-query";
+import { Check, ChevronDown, FolderOpen, Loader2, X } from "lucide-react";
+import { useState } from "react";
+
+type MapGroupSelectorProps = {
+ teamId: number;
+ value: number[];
+ onChange: (groupIds: number[]) => void;
+ multiSelect?: boolean;
+ placeholder?: string;
+};
+
+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;
+}
+
+export function MapGroupSelector({
+ teamId,
+ value,
+ onChange,
+ multiSelect = false,
+ placeholder = "Select map group...",
+}: MapGroupSelectorProps) {
+ const [open, setOpen] = useState(false);
+
+ const { data: mapGroups, isLoading } = useQuery({
+ queryKey: ["mapGroups", teamId],
+ queryFn: () => fetchMapGroups(teamId),
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const selectedGroups = mapGroups?.filter((group) => value.includes(group.id));
+
+ function handleSelect(groupId: number) {
+ if (multiSelect) {
+ const newValue = value.includes(groupId)
+ ? value.filter((id) => id !== groupId)
+ : [...value, groupId];
+ onChange(newValue);
+ } else {
+ onChange(value.includes(groupId) ? [] : [groupId]);
+ setOpen(false);
+ }
+ }
+
+ function handleRemove(groupId: number, e?: React.MouseEvent) {
+ e?.stopPropagation();
+ onChange(value.filter((id) => id !== groupId));
+ }
+
+ function handleClearAll(e: React.MouseEvent) {
+ e.stopPropagation();
+ onChange([]);
+ }
+
+ // Group by category
+ const groupsByCategory = mapGroups?.reduce(
+ (acc, group) => {
+ const category = group.category ?? "Uncategorized";
+ if (!acc[category]) {
+ acc[category] = [];
+ }
+ acc[category].push(group);
+ return acc;
+ },
+ {} as Record
+ );
+
+ const categories = groupsByCategory
+ ? Object.keys(groupsByCategory).sort((a, b) => {
+ // Sort "Uncategorized" last
+ if (a === "Uncategorized") return 1;
+ if (b === "Uncategorized") return -1;
+ return a.localeCompare(b);
+ })
+ : [];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ No map groups found
+
+
+ )}
+
+ {categories.map((category, idx) => (
+
+
+ {groupsByCategory![category].map((group) => {
+ const isSelected = value.includes(group.id);
+ return (
+ handleSelect(group.id)}
+ className="cursor-pointer"
+ >
+
+
+
+
+
+ {group.name}
+
+
+
+ {group.mapCount} map
+ {group.mapCount !== 1 ? "s" : ""}
+
+ {group.description && (
+ <>
+ ·
+
+ {group.description}
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+ {idx < categories.length - 1 &&
}
+
+ ))}
+
+
+
+
+ );
+}
From 48a343c8ff9ad6ce2d8ff7ac2a0a30062ce1e7b7 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:07:10 -0500
Subject: [PATCH 038/103] Add API routes for managing map groups, including
creation, retrieval, updating, and deletion functionalities with validation
and authorization checks
---
src/app/api/compare/map-groups/[id]/route.ts | 354 +++++++++++++++++++
src/app/api/compare/map-groups/route.ts | 289 +++++++++++++++
2 files changed, 643 insertions(+)
create mode 100644 src/app/api/compare/map-groups/[id]/route.ts
create mode 100644 src/app/api/compare/map-groups/route.ts
diff --git a/src/app/api/compare/map-groups/[id]/route.ts b/src/app/api/compare/map-groups/[id]/route.ts
new file mode 100644
index 000000000..0b6bd8879
--- /dev/null
+++ b/src/app/api/compare/map-groups/[id]/route.ts
@@ -0,0 +1,354 @@
+import { deleteMapGroup, updateMapGroup } from "@/data/map-group-dto";
+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";
+import { z } from "zod";
+
+const UpdateMapGroupSchema = z.object({
+ name: z
+ .string()
+ .min(1, "Name is required")
+ .max(100, "Name is too long")
+ .optional(),
+ description: z.string().max(500, "Description is too long").optional(),
+ mapIds: z
+ .array(z.number())
+ .min(1, "At least one map must be selected")
+ .optional(),
+ category: z.string().max(50, "Category is too long").optional(),
+});
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const startTime = Date.now();
+ const wideEvent: Record = {
+ method: "PUT",
+ path: "/api/compare/map-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 body = await request.json();
+ const validatedData = UpdateMapGroupSchema.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 group = await prisma.mapGroup.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: "Map group not found" };
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Map 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;
+
+ if (!isOwner && !isTeamOwner && !isAdmin) {
+ wideEvent.status_code = 403;
+ wideEvent.outcome = "forbidden";
+ wideEvent.error = {
+ message: "User does not have permission to update this group",
+ };
+ wideEvent.permissions = {
+ is_owner: isOwner,
+ is_team_owner: isTeamOwner,
+ is_admin: isAdmin,
+ };
+ return NextResponse.json(
+ {
+ success: false,
+ error:
+ "You must be the group creator, team owner, or admin to update this group",
+ },
+ { status: 403 }
+ );
+ }
+
+ wideEvent.permissions = {
+ is_owner: isOwner,
+ is_team_owner: isTeamOwner,
+ is_admin: isAdmin,
+ };
+
+ const { name, description, mapIds, category } = validatedData.data;
+
+ const updatedGroup = await updateMapGroup(groupId, {
+ name,
+ description,
+ mapIds,
+ category,
+ });
+
+ const groupWithCreator = await prisma.mapGroup.findUnique({
+ where: { id: updatedGroup.id },
+ include: {
+ creator: {
+ select: {
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ wideEvent.status_code = 200;
+ wideEvent.outcome = "success";
+ wideEvent.result = {
+ group_id: updatedGroup.id,
+ group_name: updatedGroup.name,
+ map_count: updatedGroup.mapIds.length,
+ };
+
+ return NextResponse.json({
+ success: true,
+ group: {
+ id: updatedGroup.id,
+ name: updatedGroup.name,
+ description: updatedGroup.description,
+ category: updatedGroup.category,
+ mapIds: updatedGroup.mapIds,
+ mapCount: updatedGroup.mapIds.length,
+ createdBy:
+ groupWithCreator?.creator.name ?? groupWithCreator?.creator.email,
+ createdAt: updatedGroup.createdAt,
+ updatedAt: updatedGroup.updatedAt,
+ },
+ });
+ } 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 updating map group", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to update map group",
+ },
+ { status: 500 }
+ );
+ } finally {
+ wideEvent.duration_ms = Date.now() - startTime;
+ Logger.info(wideEvent);
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const startTime = Date.now();
+ const wideEvent: Record = {
+ method: "DELETE",
+ path: "/api/compare/map-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.mapGroup.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: "Map group not found" };
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Map 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 deleteMapGroup(groupId);
+
+ wideEvent.status_code = 200;
+ wideEvent.outcome = "success";
+ wideEvent.result = {
+ deleted_group_id: groupId,
+ group_name: group.name,
+ };
+
+ return NextResponse.json({
+ success: true,
+ message: "Map 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 map group", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to delete map group",
+ },
+ { status: 500 }
+ );
+ } finally {
+ wideEvent.duration_ms = Date.now() - startTime;
+ Logger.info(wideEvent);
+ }
+}
diff --git a/src/app/api/compare/map-groups/route.ts b/src/app/api/compare/map-groups/route.ts
new file mode 100644
index 000000000..c7527a05e
--- /dev/null
+++ b/src/app/api/compare/map-groups/route.ts
@@ -0,0 +1,289 @@
+import { createMapGroup } from "@/data/map-group-dto";
+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";
+import { z } from "zod";
+
+const CreateMapGroupSchema = 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"),
+ mapIds: z.array(z.number()).min(1, "At least one map must be selected"),
+ category: z.string().max(50, "Category is too long").optional(),
+});
+
+export async function GET(request: NextRequest) {
+ const startTime = Date.now();
+ const wideEvent: Record = {
+ method: "GET",
+ path: "/api/compare/map-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 categoryParam = request.nextUrl.searchParams.get("category");
+
+ 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 = {
+ category: categoryParam,
+ };
+
+ const groups = await prisma.mapGroup.findMany({
+ where: {
+ teamId,
+ ...(categoryParam ? { category: categoryParam } : {}),
+ },
+ include: {
+ creator: {
+ select: {
+ name: true,
+ email: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ const formattedGroups = groups.map((group) => ({
+ id: group.id,
+ name: group.name,
+ description: group.description,
+ category: group.category,
+ 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_category: !!categoryParam,
+ };
+
+ 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 map groups", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to fetch map 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 = {
+ method: "POST",
+ path: "/api/compare/map-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 = CreateMapGroupSchema.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, mapIds, category } = 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 create map groups",
+ },
+ { status: 403 }
+ );
+ }
+
+ wideEvent.team = { id: teamId, name: team.name };
+ wideEvent.group = {
+ name,
+ map_count: mapIds.length,
+ category,
+ };
+
+ const group = await createMapGroup({
+ name,
+ description,
+ teamId,
+ mapIds,
+ category,
+ createdBy: user.id,
+ });
+
+ const groupWithCreator = await prisma.mapGroup.findUnique({
+ where: { id: group.id },
+ 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,
+ category: group.category,
+ mapIds: group.mapIds,
+ mapCount: group.mapIds.length,
+ createdBy:
+ groupWithCreator?.creator.name ?? groupWithCreator?.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 map group", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to create map group",
+ },
+ { status: 500 }
+ );
+ } finally {
+ wideEvent.duration_ms = Date.now() - startTime;
+ Logger.info(wideEvent);
+ }
+}
From e113b47bdbc74bfce9308330e3274c967aac91fb Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:07:23 -0500
Subject: [PATCH 039/103] Add data access functions for managing map groups
---
src/data/map-group-dto.ts | 110 ++++++++++++++++++++++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 src/data/map-group-dto.ts
diff --git a/src/data/map-group-dto.ts b/src/data/map-group-dto.ts
new file mode 100644
index 000000000..83540a268
--- /dev/null
+++ b/src/data/map-group-dto.ts
@@ -0,0 +1,110 @@
+import "server-only";
+
+import prisma from "@/lib/prisma";
+import type { MapGroup } from "@prisma/client";
+import { cache } from "react";
+
+/**
+ * Get all map groups for a team
+ */
+export const getMapGroupsForTeam = cache(
+ async (teamId: number): Promise => {
+ return await prisma.mapGroup.findMany({
+ where: {
+ teamId,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+ }
+);
+
+/**
+ * Get a specific map group by ID
+ */
+export const getMapGroupById = cache(
+ async (groupId: number): Promise => {
+ return await prisma.mapGroup.findUnique({
+ where: {
+ id: groupId,
+ },
+ });
+ }
+);
+
+/**
+ * Create a new map group
+ */
+export async function createMapGroup(data: {
+ name: string;
+ description?: string;
+ teamId: number;
+ mapIds: number[];
+ category?: string;
+ createdBy: string;
+}): Promise {
+ return await prisma.mapGroup.create({
+ data: {
+ name: data.name,
+ description: data.description,
+ teamId: data.teamId,
+ mapIds: data.mapIds,
+ category: data.category,
+ createdBy: data.createdBy,
+ },
+ });
+}
+
+/**
+ * Update an existing map group
+ */
+export async function updateMapGroup(
+ groupId: number,
+ data: {
+ name?: string;
+ description?: string;
+ mapIds?: number[];
+ category?: string;
+ }
+): Promise {
+ return await prisma.mapGroup.update({
+ where: {
+ id: groupId,
+ },
+ data: {
+ name: data.name,
+ description: data.description,
+ mapIds: data.mapIds,
+ category: data.category,
+ },
+ });
+}
+
+/**
+ * Delete a map group
+ */
+export async function deleteMapGroup(groupId: number): Promise {
+ await prisma.mapGroup.delete({
+ where: {
+ id: groupId,
+ },
+ });
+}
+
+/**
+ * Get map groups by category
+ */
+export const getMapGroupsByCategory = cache(
+ async (teamId: number, category: string): Promise => {
+ return await prisma.mapGroup.findMany({
+ where: {
+ teamId,
+ category,
+ },
+ orderBy: {
+ name: "asc",
+ },
+ });
+ }
+);
From b53ea75c219e92e0a0c0808c493dd1099393cf09 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:07:41 -0500
Subject: [PATCH 040/103] Add TypeScript types for map groups, including
creator information and request bodies for creation and updates
---
src/types/map-group.ts | 47 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 47 insertions(+)
create mode 100644 src/types/map-group.ts
diff --git a/src/types/map-group.ts b/src/types/map-group.ts
new file mode 100644
index 000000000..f9bd120fb
--- /dev/null
+++ b/src/types/map-group.ts
@@ -0,0 +1,47 @@
+import type { MapGroup } from "@prisma/client";
+
+/**
+ * Map group with creator information
+ */
+export type MapGroupWithCreator = MapGroup & {
+ creator: {
+ name: string | null;
+ email: string;
+ };
+};
+
+/**
+ * Formatted map group for API responses
+ */
+export type FormattedMapGroup = {
+ id: number;
+ name: string;
+ description: string | null;
+ category: string | null;
+ mapIds: number[];
+ mapCount: number;
+ createdBy: string;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+/**
+ * Request body for creating a map group
+ */
+export type CreateMapGroupRequest = {
+ name: string;
+ description?: string;
+ teamId: number;
+ mapIds: number[];
+ category?: string;
+};
+
+/**
+ * Request body for updating a map group
+ */
+export type UpdateMapGroupRequest = {
+ name?: string;
+ description?: string;
+ mapIds?: number[];
+ category?: string;
+};
From 2686b62dfc0d5b65ec6a47601200f12dd4722bc2 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:25:40 -0500
Subject: [PATCH 041/103] Add TypeScript types for team comparison statistics,
including aggregated stats and per-map breakdowns
---
src/types/team-comparison.ts | 45 ++++++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 src/types/team-comparison.ts
diff --git a/src/types/team-comparison.ts b/src/types/team-comparison.ts
new file mode 100644
index 000000000..badebf999
--- /dev/null
+++ b/src/types/team-comparison.ts
@@ -0,0 +1,45 @@
+import type { AggregatedStats } from "@/data/comparison-dto";
+
+/**
+ * Team comparison result showing both teams' aggregated stats
+ */
+export type TeamComparisonStats = {
+ mapCount: number;
+ mapIds: number[];
+ myTeam: TeamAggregatedStats;
+ enemyTeam: TeamAggregatedStats;
+ perMapBreakdown: TeamMapBreakdown[];
+};
+
+/**
+ * Aggregated stats for a team with team-specific metadata
+ */
+export type TeamAggregatedStats = {
+ teamName: string;
+ playerCount: number;
+ stats: AggregatedStats;
+ roleBreakdown?: {
+ tank?: AggregatedStats;
+ dps?: AggregatedStats;
+ support?: AggregatedStats;
+ };
+};
+
+/**
+ * Per-map breakdown showing team performance on each map
+ */
+export type TeamMapBreakdown = {
+ mapId: number;
+ mapDataId: number;
+ mapName: string;
+ mapType: string;
+ scrimId: number;
+ scrimName: string;
+ date: Date;
+ replayCode: string | null;
+ myTeamName: string;
+ enemyTeamName: string;
+ myTeamStats: Partial;
+ enemyTeamStats: Partial;
+ winner: "myTeam" | "enemyTeam" | "draw" | null;
+};
From ea8e6aba30eb27b57fe001bf20173327511b415c Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:25:56 -0500
Subject: [PATCH 042/103] Add team comparison DTO with functions for
aggregating player and calculated stats, including per-map breakdowns and
overall team statistics
---
src/data/team-comparison-dto.ts | 542 ++++++++++++++++++++++++++++++++
1 file changed, 542 insertions(+)
create mode 100644 src/data/team-comparison-dto.ts
diff --git a/src/data/team-comparison-dto.ts b/src/data/team-comparison-dto.ts
new file mode 100644
index 000000000..89c503ccd
--- /dev/null
+++ b/src/data/team-comparison-dto.ts
@@ -0,0 +1,542 @@
+import "server-only";
+
+import prisma from "@/lib/prisma";
+import { calculateWinner } from "@/lib/winrate";
+import type { HeroName } from "@/types/heroes";
+import type {
+ TeamComparisonStats,
+ TeamMapBreakdown,
+} from "@/types/team-comparison";
+import type { CalculatedStat, MapType, PlayerStat } from "@prisma/client";
+import { CalculatedStatType } from "@prisma/client";
+import { cache } from "react";
+import type { AggregatedStats } from "./comparison-dto";
+import { findTeamNameForMapInMemory, getTeamRoster } from "./team-shared-data";
+
+/**
+ * Calculates per-10 value for a stat
+ */
+function calculatePer10(value: number, timePlayed: number): number {
+ if (timePlayed === 0) return 0;
+ return (value / timePlayed) * 600;
+}
+
+/**
+ * Calculates percentage
+ */
+function calculatePercentage(value: number, total: number): number {
+ if (total === 0) return 0;
+ return (value / total) * 100;
+}
+
+/**
+ * Aggregates calculated stats for a team
+ */
+function aggregateCalculatedStatsForTeam(
+ 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;
+ }
+ });
+
+ // Average percentage-based stats
+ 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;
+}
+
+/**
+ * Aggregates player stats for a team
+ */
+function aggregateTeamStats(
+ 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 = aggregateCalculatedStatsForTeam(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,
+ // Team comparisons don't calculate variance metrics
+ eliminationsPer10StdDev: 0,
+ deathsPer10StdDev: 0,
+ allDamagePer10StdDev: 0,
+ healingDealtPer10StdDev: 0,
+ firstPickPercentageStdDev: 0,
+ consistencyScore: 0,
+ };
+}
+
+/**
+ * Gets team comparison stats for a set of maps
+ */
+async function getTeamComparisonStatsFn(
+ mapIds: number[],
+ teamId: number,
+ heroes?: HeroName[]
+): Promise {
+ if (mapIds.length === 0) {
+ throw new Error("At least one map must be provided");
+ }
+
+ // Get team roster
+ const teamRoster = await getTeamRoster(teamId);
+ const teamRosterSet = new Set(teamRoster);
+
+ if (teamRoster.length === 0) {
+ throw new Error("No team roster found");
+ }
+
+ // Fetch map data
+ const maps = await prisma.map.findMany({
+ where: { id: { in: mapIds } },
+ include: {
+ Scrim: true,
+ mapData: {
+ include: {
+ match_start: true,
+ round_end: true,
+ objective_captured: 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");
+ }
+
+ // Fetch all player stats
+ const allPlayerStats = await prisma.playerStat.findMany({
+ where: {
+ MapDataId: { in: mapIds },
+ ...(heroes && heroes.length > 0 ? { player_hero: { in: heroes } } : {}),
+ },
+ });
+
+ // Fetch calculated stats
+ const allCalculatedStats = await prisma.calculatedStat.findMany({
+ where: {
+ MapDataId: { in: mapDataIds },
+ ...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}),
+ },
+ });
+
+ // Map MapData IDs back to Map IDs
+ const mapDataIdToMapId = new Map();
+ for (const map of maps) {
+ for (const mapData of map.mapData) {
+ mapDataIdToMapId.set(mapData.id, map.id);
+ }
+ }
+
+ // Separate stats by team (my team vs enemy team)
+ const myTeamStats: PlayerStat[] = [];
+ const enemyTeamStats: PlayerStat[] = [];
+ const myTeamCalculatedStats: CalculatedStat[] = [];
+ const enemyTeamCalculatedStats: CalculatedStat[] = [];
+
+ // Per-map breakdown
+ const perMapBreakdown: TeamMapBreakdown[] = [];
+
+ for (const map of maps) {
+ const mapPlayerStats = allPlayerStats.filter(
+ (stat) => stat.MapDataId === map.id
+ );
+
+ if (mapPlayerStats.length === 0) continue;
+
+ // Find team name for this map
+ const myTeamName = findTeamNameForMapInMemory(
+ map.id,
+ mapPlayerStats,
+ teamRosterSet
+ );
+
+ if (!myTeamName) continue;
+
+ // Get match start for metadata
+ const firstMapData = map.mapData[0];
+ const matchStart = firstMapData?.match_start[0];
+
+ // Get final round and captures for winner calculation
+ const roundEnds = firstMapData?.round_end ?? [];
+ const finalRound =
+ roundEnds.length > 0
+ ? roundEnds.reduce((latest, current) =>
+ current.round_number > latest.round_number ? current : latest
+ )
+ : null;
+
+ const allCaptures = firstMapData?.objective_captured ?? [];
+ const team1Captures = allCaptures.filter(
+ (c) => c.capturing_team === matchStart?.team_1_name
+ );
+ const team2Captures = allCaptures.filter(
+ (c) => c.capturing_team === matchStart?.team_2_name
+ );
+
+ // Determine enemy team name
+ const enemyTeamName =
+ matchStart?.team_1_name === myTeamName
+ ? matchStart?.team_2_name
+ : matchStart?.team_1_name;
+
+ // Separate stats by team for this map
+ const myTeamMapStats = mapPlayerStats.filter(
+ (stat) => stat.player_team === myTeamName
+ );
+ const enemyTeamMapStats = mapPlayerStats.filter(
+ (stat) => stat.player_team === enemyTeamName
+ );
+
+ myTeamStats.push(...myTeamMapStats);
+ enemyTeamStats.push(...enemyTeamMapStats);
+
+ // Separate calculated stats
+ const mapCalculatedStats = allCalculatedStats.filter(
+ (stat) => mapDataIdToMapId.get(stat.MapDataId) === map.id
+ );
+
+ const myTeamMapCalculatedStats = mapCalculatedStats.filter((stat) =>
+ teamRosterSet.has(stat.playerName)
+ );
+ const enemyTeamMapCalculatedStats = mapCalculatedStats.filter(
+ (stat) => !teamRosterSet.has(stat.playerName)
+ );
+
+ myTeamCalculatedStats.push(...myTeamMapCalculatedStats);
+ enemyTeamCalculatedStats.push(...enemyTeamMapCalculatedStats);
+
+ // Calculate aggregated stats for this map
+ const myTeamMapAggregated = aggregateTeamStats(
+ myTeamMapStats,
+ myTeamMapCalculatedStats
+ );
+ const enemyTeamMapAggregated = aggregateTeamStats(
+ enemyTeamMapStats,
+ enemyTeamMapCalculatedStats
+ );
+
+ // Determine winner for this map
+ const winnerTeamName = calculateWinner({
+ matchDetails: matchStart ?? null,
+ finalRound: finalRound ?? null,
+ team1Captures,
+ team2Captures,
+ });
+
+ const winner: TeamMapBreakdown["winner"] =
+ winnerTeamName === myTeamName
+ ? "myTeam"
+ : winnerTeamName === enemyTeamName
+ ? "enemyTeam"
+ : winnerTeamName === "N/A"
+ ? null
+ : "draw";
+
+ 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,
+ myTeamName: myTeamName ?? "My Team",
+ enemyTeamName: enemyTeamName ?? "Enemy Team",
+ myTeamStats: myTeamMapAggregated,
+ enemyTeamStats: enemyTeamMapAggregated,
+ winner,
+ });
+ }
+
+ // Sort by date
+ perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime());
+
+ // Aggregate overall stats
+ const myTeamAggregated = aggregateTeamStats(
+ myTeamStats,
+ myTeamCalculatedStats
+ );
+ const enemyTeamAggregated = aggregateTeamStats(
+ enemyTeamStats,
+ enemyTeamCalculatedStats
+ );
+
+ // Get team names from first map
+ const firstMapTeamName =
+ perMapBreakdown.length > 0 ? perMapBreakdown[0].myTeamName : "My Team";
+ const firstMapEnemyTeamName =
+ perMapBreakdown.length > 0
+ ? perMapBreakdown[0].enemyTeamName
+ : "Enemy Team";
+
+ // Count unique players
+ const myTeamPlayerSet = new Set(myTeamStats.map((stat) => stat.player_name));
+ const enemyTeamPlayerSet = new Set(
+ enemyTeamStats.map((stat) => stat.player_name)
+ );
+
+ return {
+ mapCount: perMapBreakdown.length,
+ mapIds,
+ myTeam: {
+ teamName: firstMapTeamName,
+ playerCount: myTeamPlayerSet.size,
+ stats: myTeamAggregated,
+ },
+ enemyTeam: {
+ teamName: firstMapEnemyTeamName,
+ playerCount: enemyTeamPlayerSet.size,
+ stats: enemyTeamAggregated,
+ },
+ perMapBreakdown,
+ };
+}
+
+export const getTeamComparisonStats = cache(getTeamComparisonStatsFn);
From 02d6648f98038ee2561901032cd66ffac40ec19e Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:26:05 -0500
Subject: [PATCH 043/103] Add TeamComparisonView component for displaying
comparative statistics between teams
---
.../compare/team-comparison-view.tsx | 317 ++++++++++++++++++
1 file changed, 317 insertions(+)
create mode 100644 src/components/compare/team-comparison-view.tsx
diff --git a/src/components/compare/team-comparison-view.tsx b/src/components/compare/team-comparison-view.tsx
new file mode 100644
index 000000000..1f82e8be2
--- /dev/null
+++ b/src/components/compare/team-comparison-view.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { TeamComparisonStats } from "@/types/team-comparison";
+import { ArrowDown, ArrowUp, Minus, Users } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type TeamComparisonViewProps = {
+ stats: TeamComparisonStats;
+};
+
+type StatComparison = {
+ label: string;
+ myTeamValue: number;
+ enemyTeamValue: number;
+ format: "number" | "per10" | "percentage" | "time" | "ratio";
+ reverseColors?: boolean;
+ suffix?: string;
+};
+
+function formatStatValue(
+ value: number,
+ format: "number" | "per10" | "percentage" | "time" | "ratio",
+ suffix?: string
+): string {
+ if (format === "time") {
+ const minutes = Math.floor(value / 60);
+ const seconds = Math.floor(value % 60);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ }
+ if (format === "percentage") {
+ return `${value.toFixed(1)}%`;
+ }
+ if (format === "ratio") {
+ return value.toFixed(2);
+ }
+ if (format === "per10") {
+ return value.toFixed(1) + (suffix ?? "");
+ }
+ return value.toLocaleString() + (suffix ?? "");
+}
+
+function getComparisonIndicator(
+ myTeamValue: number,
+ enemyTeamValue: number,
+ reverseColors = false
+) {
+ const diff = myTeamValue - enemyTeamValue;
+ const percentChange =
+ enemyTeamValue !== 0 ? Math.abs((diff / enemyTeamValue) * 100) : 0;
+
+ if (percentChange < 2) {
+ return {
+ icon: Minus,
+ colorClass: "text-muted-foreground",
+ bgClass: "bg-muted/40",
+ label: "neutral",
+ };
+ }
+
+ const isAdvantage = reverseColors ? diff < 0 : diff > 0;
+
+ return {
+ icon: diff > 0 ? ArrowUp : ArrowDown,
+ colorClass: isAdvantage
+ ? "text-emerald-600 dark:text-emerald-400"
+ : "text-rose-600 dark:text-rose-400",
+ bgClass: isAdvantage
+ ? "bg-emerald-50 dark:bg-emerald-950/30"
+ : "bg-rose-50 dark:bg-rose-950/30",
+ label: isAdvantage ? "advantage" : "disadvantage",
+ };
+}
+
+export function TeamComparisonView({ stats }: TeamComparisonViewProps) {
+ const t = useTranslations("comparePage.teamComparison");
+
+ const combatStats: StatComparison[] = [
+ {
+ label: t("stats.eliminationsPer10"),
+ myTeamValue: stats.myTeam.stats.eliminationsPer10,
+ enemyTeamValue: stats.enemyTeam.stats.eliminationsPer10,
+ format: "per10",
+ },
+ {
+ label: t("stats.finalBlowsPer10"),
+ myTeamValue: stats.myTeam.stats.finalBlowsPer10,
+ enemyTeamValue: stats.enemyTeam.stats.finalBlowsPer10,
+ format: "per10",
+ },
+ {
+ label: t("stats.deathsPer10"),
+ myTeamValue: stats.myTeam.stats.deathsPer10,
+ enemyTeamValue: stats.enemyTeam.stats.deathsPer10,
+ format: "per10",
+ reverseColors: true,
+ },
+ {
+ label: t("stats.firstPickPercentage"),
+ myTeamValue: stats.myTeam.stats.firstPickPercentage,
+ enemyTeamValue: stats.enemyTeam.stats.firstPickPercentage,
+ format: "percentage",
+ },
+ {
+ label: t("stats.firstDeathPercentage"),
+ myTeamValue: stats.myTeam.stats.firstDeathPercentage,
+ enemyTeamValue: stats.enemyTeam.stats.firstDeathPercentage,
+ format: "percentage",
+ reverseColors: true,
+ },
+ ];
+
+ const damageStats: StatComparison[] = [
+ {
+ label: t("stats.heroDamagePer10"),
+ myTeamValue: stats.myTeam.stats.heroDamagePer10,
+ enemyTeamValue: stats.enemyTeam.stats.heroDamagePer10,
+ format: "per10",
+ },
+ {
+ label: t("stats.damageTakenPer10"),
+ myTeamValue: stats.myTeam.stats.damageTakenPer10,
+ enemyTeamValue: stats.enemyTeam.stats.damageTakenPer10,
+ format: "per10",
+ reverseColors: true,
+ },
+ ];
+
+ const supportStats: StatComparison[] = [
+ {
+ label: t("stats.healingDealtPer10"),
+ myTeamValue: stats.myTeam.stats.healingDealtPer10,
+ enemyTeamValue: stats.enemyTeam.stats.healingDealtPer10,
+ format: "per10",
+ },
+ {
+ label: t("stats.damageBlockedPer10"),
+ myTeamValue: stats.myTeam.stats.damageBlockedPer10,
+ enemyTeamValue: stats.enemyTeam.stats.damageBlockedPer10,
+ format: "per10",
+ },
+ ];
+
+ const ultimateStats: StatComparison[] = [
+ {
+ label: t("stats.ultimatesEarnedPer10"),
+ myTeamValue: stats.myTeam.stats.ultimatesEarnedPer10,
+ enemyTeamValue: stats.enemyTeam.stats.ultimatesEarnedPer10,
+ format: "per10",
+ },
+ {
+ label: t("stats.averageUltChargeTime"),
+ myTeamValue: stats.myTeam.stats.averageUltChargeTime,
+ enemyTeamValue: stats.enemyTeam.stats.averageUltChargeTime,
+ format: "time",
+ reverseColors: true,
+ },
+ {
+ label: t("stats.killsPerUltimate"),
+ myTeamValue: stats.myTeam.stats.killsPerUltimate,
+ enemyTeamValue: stats.enemyTeam.stats.killsPerUltimate,
+ format: "ratio",
+ },
+ ];
+
+ function renderStatComparison(stat: StatComparison) {
+ const indicator = getComparisonIndicator(
+ stat.myTeamValue,
+ stat.enemyTeamValue,
+ stat.reverseColors
+ );
+ const Icon = indicator.icon;
+
+ return (
+
+ {/* My Team Value */}
+
+
+ {formatStatValue(stat.myTeamValue, stat.format, stat.suffix)}
+
+ {indicator.label === "advantage" && (
+
+
+
+ )}
+
+
+ {/* Stat Label */}
+
+
+ {stat.label}
+
+
+
+ {/* Enemy Team Value */}
+
+ {indicator.label === "disadvantage" && (
+
+
+
+ )}
+
+ {formatStatValue(stat.enemyTeamValue, stat.format, stat.suffix)}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Team Headers */}
+
+
+
+
+
+
+
+
+
+ {stats.myTeam.teamName}
+
+
+ {t("myTeam")} • {stats.myTeam.playerCount}{" "}
+ {t("players", { count: stats.myTeam.playerCount })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {stats.enemyTeam.teamName}
+
+
+ {t("enemyTeam")} • {stats.enemyTeam.playerCount}{" "}
+ {t("players", { count: stats.enemyTeam.playerCount })}
+
+
+
+
+
+
+
+ {/* Context Badge */}
+
+
+ {t("comparingMaps", { count: stats.mapCount })}
+
+
+
+ {/* Combat Stats */}
+
+
+
+
+ {t("categories.combat")}
+
+
+
+ {combatStats.map((stat) => renderStatComparison(stat))}
+
+
+
+ {/* Damage Stats */}
+
+
+
+
+ {t("categories.damage")}
+
+
+
+ {damageStats.map((stat) => renderStatComparison(stat))}
+
+
+
+ {/* Support Stats */}
+
+
+
+
+ {t("categories.support")}
+
+
+
+ {supportStats.map((stat) => renderStatComparison(stat))}
+
+
+
+ {/* Ultimate Stats */}
+
+
+
+
+ {t("categories.ultimate")}
+
+
+
+ {ultimateStats.map((stat) => renderStatComparison(stat))}
+
+
+
+ );
+}
From 1fa2822b9dfa6568ac8ecea5da492cb0a5a1a8f4 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:26:10 -0500
Subject: [PATCH 044/103] Implement comparison mode toggle and integrate team
comparison stats fetching in ComparisonContent component
---
src/components/compare/comparison-content.tsx | 166 +++++++++++++++---
1 file changed, 142 insertions(+), 24 deletions(-)
diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx
index c81143b34..cfcf80752 100644
--- a/src/components/compare/comparison-content.tsx
+++ b/src/components/compare/comparison-content.tsx
@@ -1,9 +1,12 @@
"use client";
import { Card } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ComparisonStats } from "@/data/comparison-dto";
import type { HeroName } from "@/types/heroes";
+import type { TeamComparisonStats } from "@/types/team-comparison";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -15,6 +18,7 @@ import { ConsistencyView } from "./consistency-view";
import { DeltaView } from "./delta-view";
import { EmptyState } from "./empty-state";
import { SideBySideView } from "./side-by-side-view";
+import { TeamComparisonView } from "./team-comparison-view";
import { TrendsView } from "./trends-view";
type ComparisonContentProps = {
@@ -23,6 +27,7 @@ type ComparisonContentProps = {
};
type ViewMode = "side-by-side" | "delta" | "trends" | "charts" | "consistency";
+type ComparisonMode = "player" | "team";
async function fetchComparisonStats(
mapIds: number[],
@@ -47,6 +52,31 @@ async function fetchComparisonStats(
return data.data;
}
+async function fetchTeamComparisonStats(
+ mapIds: number[],
+ teamId: number,
+ heroes?: HeroName[]
+): Promise {
+ const params = new URLSearchParams({
+ mapIds: JSON.stringify(mapIds),
+ teamId: teamId.toString(),
+ });
+
+ if (heroes && heroes.length > 0) {
+ params.set("heroes", heroes.join(","));
+ }
+
+ const response = await fetch(
+ `/api/compare/team-vs-team?${params.toString()}`
+ );
+ if (!response.ok) {
+ throw new Error("Failed to fetch team comparison stats");
+ }
+
+ const data = (await response.json()) as { data: TeamComparisonStats };
+ return data.data;
+}
+
export function ComparisonContent({ teamId }: ComparisonContentProps) {
const t = useTranslations("comparePage");
const searchParams = useSearchParams();
@@ -64,11 +94,15 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{ from: Date; to: Date } | undefined
>(undefined);
+ // Comparison mode state (player vs team)
+ const [comparisonMode, setComparisonMode] =
+ useState("player");
+
// View mode state
const [activeView, setActiveView] = useState("side-by-side");
- // Fetch comparison stats
- const { data: comparisonStats, isLoading } = useQuery({
+ // Fetch comparison stats (player mode)
+ const { data: comparisonStats, isLoading: isLoadingPlayer } = useQuery({
queryKey: [
"comparisonStats",
selectedMapIds,
@@ -81,20 +115,45 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
selectedPlayer!,
selectedHeroes.length > 0 ? selectedHeroes : undefined
),
- enabled: selectedMapIds.length > 0 && !!selectedPlayer,
+ enabled:
+ comparisonMode === "player" &&
+ selectedMapIds.length > 0 &&
+ !!selectedPlayer,
staleTime: 5 * 60 * 1000,
});
- // Determine available views based on map count
+ // Fetch team comparison stats (team mode)
+ const { data: teamComparisonStats, isLoading: isLoadingTeam } = useQuery({
+ queryKey: ["teamComparisonStats", selectedMapIds, teamId, selectedHeroes],
+ queryFn: () =>
+ fetchTeamComparisonStats(
+ selectedMapIds,
+ teamId,
+ selectedHeroes.length > 0 ? selectedHeroes : undefined
+ ),
+ enabled: comparisonMode === "team" && selectedMapIds.length > 0,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const isLoading =
+ comparisonMode === "player" ? isLoadingPlayer : isLoadingTeam;
+
+ // Determine available views based on map count and comparison mode
const availableViews: ViewMode[] = useMemo(() => {
+ // Team comparison doesn't support all views
+ if (comparisonMode === "team") {
+ return selectedMapIds.length >= 2 ? ["side-by-side"] : [];
+ }
+
+ // Player comparison views
return selectedMapIds.length === 2
? ["side-by-side", "delta", "charts", "consistency"]
: selectedMapIds.length >= 3
? ["trends", "charts", "consistency"]
: [];
- }, [selectedMapIds.length]);
+ }, [selectedMapIds.length, comparisonMode]);
- // Auto-switch view when map selection changes
+ // Auto-switch view when map selection or comparison mode changes
useEffect(() => {
if (availableViews.length > 0 && !availableViews.includes(activeView)) {
setActiveView(availableViews[0]);
@@ -130,19 +189,70 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{/* Filters */}
-
+
+ {/* Comparison Mode Toggle */}
+
+
+
+
+ {t("comparisonMode.label")}
+
+
+ {comparisonMode === "player"
+ ? t("comparisonMode.playerDescription")
+ : t("comparisonMode.teamDescription")}
+
+
+
+
+ {t("comparisonMode.player")}
+
+
+ setComparisonMode(checked ? "team" : "player")
+ }
+ />
+
+ {t("comparisonMode.team")}
+
+
+
+
+
+ {/* Player Filters (only show in player mode) */}
+ {comparisonMode === "player" && (
+
+ )}
+
{/* Content */}
- {!selectedPlayer ? (
+ {comparisonMode === "player" && !selectedPlayer ? (
{t("loading")}
- ) : !comparisonStats ? (
+ ) : comparisonMode === "player" && !comparisonStats ? (
+ ) : comparisonMode === "team" && !teamComparisonStats ? (
+
+ ) : comparisonMode === "team" ? (
+
) : (
-
+
)}
{availableViews.includes("delta") && (
-
+
)}
{availableViews.includes("trends") && (
-
+
)}
{availableViews.includes("charts") && (
@@ -219,7 +337,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{availableViews.includes("consistency") && (
-
+
)}
From 43fd7db28a36333e6e20d52abde0edfb780e3811 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:26:14 -0500
Subject: [PATCH 045/103] Add API route for team vs team comparison, including
parameter validation and error handling
---
src/app/api/compare/team-vs-team/route.ts | 59 +++++++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 src/app/api/compare/team-vs-team/route.ts
diff --git a/src/app/api/compare/team-vs-team/route.ts b/src/app/api/compare/team-vs-team/route.ts
new file mode 100644
index 000000000..c0bc75708
--- /dev/null
+++ b/src/app/api/compare/team-vs-team/route.ts
@@ -0,0 +1,59 @@
+import { getTeamComparisonStats } from "@/data/team-comparison-dto";
+import { Logger } from "@/lib/logger";
+import type { HeroName } from "@/types/heroes";
+import { NextResponse } from "next/server";
+
+export async function GET(request: Request) {
+ try {
+ const { searchParams } = new URL(request.url);
+
+ const mapIdsParam = searchParams.get("mapIds");
+ const teamIdParam = searchParams.get("teamId");
+ const heroesParam = searchParams.get("heroes");
+
+ if (!mapIdsParam || !teamIdParam) {
+ return NextResponse.json(
+ {
+ error: "Missing required parameters: mapIds and teamId are required",
+ },
+ { status: 400 }
+ );
+ }
+
+ const mapIds = JSON.parse(mapIdsParam) as number[];
+ const teamId = parseInt(teamIdParam, 10);
+
+ if (!Array.isArray(mapIds) || mapIds.length === 0) {
+ return NextResponse.json(
+ { error: "mapIds must be a non-empty array" },
+ { status: 400 }
+ );
+ }
+
+ if (isNaN(teamId)) {
+ return NextResponse.json(
+ { error: "teamId must be a valid number" },
+ { status: 400 }
+ );
+ }
+
+ const heroes = heroesParam
+ ? (heroesParam.split(",") as HeroName[])
+ : undefined;
+
+ const stats = await getTeamComparisonStats(mapIds, teamId, heroes);
+
+ return NextResponse.json({ data: stats });
+ } catch (error) {
+ Logger.error("Error fetching team comparison stats:", error);
+
+ if (error instanceof Error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
From cc4ed031e565fb7153380ac65d7e5dc1a24a9610 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:26:19 -0500
Subject: [PATCH 046/103] Add localization strings for team comparison features
---
messages/en.json | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/messages/en.json b/messages/en.json
index 52e4c828d..ba3efc35c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -724,6 +724,9 @@
"noData": {
"title": "No Data Available",
"description": "{player} did not participate in the selected maps."
+ },
+ "noTeamData": {
+ "description": "No team data available for the selected maps."
}
},
"views": {
@@ -861,6 +864,39 @@
"range": "Range",
"variation": "Variation"
}
+ },
+ "comparisonMode": {
+ "label": "Comparison Mode",
+ "player": "Player",
+ "team": "Team",
+ "playerDescription": "Compare individual player performance across maps",
+ "teamDescription": "Compare your team vs enemy team across maps"
+ },
+ "teamComparison": {
+ "myTeam": "My Team",
+ "enemyTeam": "Enemy Team",
+ "players": "{count, plural, =1 {1 player} other {# players}}",
+ "comparingMaps": "{count, plural, =1 {Comparing 1 map} other {Comparing # maps}}",
+ "categories": {
+ "combat": "Combat Performance",
+ "damage": "Damage & Survivability",
+ "support": "Support & Defense",
+ "ultimate": "Ultimate Economy"
+ },
+ "stats": {
+ "eliminationsPer10": "Eliminations per 10",
+ "finalBlowsPer10": "Final Blows per 10",
+ "deathsPer10": "Deaths per 10",
+ "firstPickPercentage": "First Pick %",
+ "firstDeathPercentage": "First Death %",
+ "heroDamagePer10": "Hero Damage per 10",
+ "damageTakenPer10": "Damage Taken per 10",
+ "healingDealtPer10": "Healing Dealt per 10",
+ "damageBlockedPer10": "Damage Blocked per 10",
+ "ultimatesEarnedPer10": "Ultimates Earned per 10",
+ "averageUltChargeTime": "Avg Ult Charge Time",
+ "killsPerUltimate": "Kills per Ultimate"
+ }
}
},
"mapPage": {
From 8b56b89690896e5d787398a0a8b18282e539d78f Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:59:10 -0500
Subject: [PATCH 047/103] Update ChartsView component to display normalized
stats per 10 for comparisons
---
src/components/compare/charts-view.tsx | 119 +++++++++++++++++++------
1 file changed, 91 insertions(+), 28 deletions(-)
diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx
index 5261d6fc3..3e0ad7cbd 100644
--- a/src/components/compare/charts-view.tsx
+++ b/src/components/compare/charts-view.tsx
@@ -58,34 +58,38 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
},
};
- // Prepare data for bar chart (side-by-side comparison for 2 maps)
+ // Prepare data for bar chart (side-by-side comparison for 2 maps) - using per-10 values
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("elimsPer10"),
+ map1: stats.perMapBreakdown[0].stats.eliminationsPer10 ?? 0,
+ map2: stats.perMapBreakdown[1].stats.eliminationsPer10 ?? 0,
},
{
- stat: t("deaths"),
- map1: stats.perMapBreakdown[0].stats.deaths ?? 0,
- map2: stats.perMapBreakdown[1].stats.deaths ?? 0,
+ stat: t("deathsPer10"),
+ map1: stats.perMapBreakdown[0].stats.deathsPer10 ?? 0,
+ map2: stats.perMapBreakdown[1].stats.deathsPer10 ?? 0,
},
{
- stat: t("damage"),
- map1: (stats.perMapBreakdown[0].stats.all_damage_dealt ?? 0) / 1000,
- map2: (stats.perMapBreakdown[1].stats.all_damage_dealt ?? 0) / 1000,
+ stat: t("damagePer10K"),
+ map1: (stats.perMapBreakdown[0].stats.allDamagePer10 ?? 0) / 1000,
+ map2: (stats.perMapBreakdown[1].stats.allDamagePer10 ?? 0) / 1000,
},
{
- stat: t("healing"),
- map1: (stats.perMapBreakdown[0].stats.healing_dealt ?? 0) / 1000,
- map2: (stats.perMapBreakdown[1].stats.healing_dealt ?? 0) / 1000,
+ stat: t("healingPer10K"),
+ map1:
+ (stats.perMapBreakdown[0].stats.healingDealtPer10 ?? 0) / 1000,
+ map2:
+ (stats.perMapBreakdown[1].stats.healingDealtPer10 ?? 0) / 1000,
},
{
- stat: t("mitigated"),
- map1: (stats.perMapBreakdown[0].stats.damage_blocked ?? 0) / 1000,
- map2: (stats.perMapBreakdown[1].stats.damage_blocked ?? 0) / 1000,
+ stat: t("blockedPer10K"),
+ map1:
+ (stats.perMapBreakdown[0].stats.damageBlockedPer10 ?? 0) / 1000,
+ map2:
+ (stats.perMapBreakdown[1].stats.damageBlockedPer10 ?? 0) / 1000,
},
]
: [];
@@ -381,20 +385,20 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
) : null}
- {/* Additional Stats Cards */}
-
+ {/* Impact Metrics Cards - Normalized Stats Only */}
+
- {t("totalEliminations")}
+ {t("firstPickPercentage")}
- {stats.aggregated.eliminations.toLocaleString()}
+ {stats.aggregated.firstPickPercentage.toFixed(1)}%
- {t("avgPer10")}: {stats.aggregated.eliminationsPer10.toFixed(2)}
+ {stats.aggregated.firstPicksPer10.toFixed(1)} {t("per10")}
@@ -402,15 +406,15 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
- {t("totalDeaths")}
+ {t("firstDeathPercentage")}
- {stats.aggregated.deaths.toLocaleString()}
+ {stats.aggregated.firstDeathPercentage.toFixed(1)}%
- {t("avgPer10")}: {stats.aggregated.deathsPer10.toFixed(2)}
+ {stats.aggregated.firstDeathsPer10.toFixed(1)} {t("per10")}
@@ -418,19 +422,78 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
- {t("totalDamage")}
+ {t("killsPerUltimate")}
- {stats.aggregated.allDamageDealt.toLocaleString()}
+ {stats.aggregated.killsPerUltimate.toFixed(2)}
+
+ {t("avgUltImpact")}
+
+
+
+
+
+
+ {t("soloKillsPer10")}
+
+
+
+
+ {stats.aggregated.soloKillsPer10.toFixed(1)}
+
+
+ {t("individualKills")}
+
+
+
+
+
+
+
+ {t("damageTakenPer10")}
+
+
+
+
+ {stats.aggregated.damageTakenPer10.toLocaleString()}
- {t("avgPer10")}:{" "}
- {stats.aggregated.allDamagePer10.toLocaleString()}
+ {t("damageReceived")}
+
+
+
+
+ {t("healingReceivedPer10")}
+
+
+
+
+ {stats.aggregated.healingReceivedPer10.toLocaleString()}
+
+
+ {t("supportReceived")}
+
+
+
+
+
+
+
+ {t("fightReversalPercentage")}
+
+
+
+
+ {stats.aggregated.fightReversalPercentage.toFixed(1)}%
+
+ {t("clutchPlays")}
+
+
);
From 861c402ea5b6dce4eab23f885ac4987dbc364975 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:59:19 -0500
Subject: [PATCH 048/103] Add detailed statistics and impact metrics
localization strings for enhanced team comparison features
---
messages/en.json | 95 +++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 94 insertions(+), 1 deletion(-)
diff --git a/messages/en.json b/messages/en.json
index ba3efc35c..bd06feb84 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -729,12 +729,88 @@
"description": "No team data available for the selected maps."
}
},
+ "detailedStats": {
+ "title": "Detailed Statistics",
+ "subtitle": "All metrics normalized per 10 minutes or as percentages for fair comparison",
+ "exportCsv": "Export CSV",
+ "statName": "Stat",
+ "maps": "maps",
+ "priority": "Priority",
+ "note": "Note: All statistics are normalized (per 10 minutes, percentages, or averages) to enable fair comparison across different playtimes.",
+ "categories": {
+ "all": "All Stats",
+ "combat": "Combat",
+ "damage": "Damage",
+ "support": "Support",
+ "defense": "Defense",
+ "assists": "Assists",
+ "ultimate": "Ultimate",
+ "impact": "Impact",
+ "consistency": "Consistency"
+ },
+ "stats": {
+ "eliminationsPer10": "Eliminations per 10",
+ "finalBlowsPer10": "Final Blows per 10",
+ "soloKillsPer10": "Solo Kills per 10",
+ "deathsPer10": "Deaths per 10",
+ "kdRatio": "K/D Ratio",
+ "allDamagePer10": "All Damage per 10",
+ "heroDamagePer10": "Hero Damage per 10",
+ "barrierDamagePer10": "Barrier Damage per 10",
+ "healingDealtPer10": "Healing Dealt per 10",
+ "healingReceivedPer10": "Healing Received per 10",
+ "selfHealingPer10": "Self Healing per 10",
+ "damageTakenPer10": "Damage Taken per 10",
+ "damageBlockedPer10": "Damage Blocked per 10",
+ "offensiveAssistsPer10": "Offensive Assists per 10",
+ "defensiveAssistsPer10": "Defensive Assists per 10",
+ "ultimatesEarnedPer10": "Ultimates Earned per 10",
+ "ultimatesUsedPer10": "Ultimates Used per 10",
+ "averageUltChargeTime": "Average Ult Charge Time",
+ "averageTimeToUseUlt": "Average Time to Use Ult",
+ "killsPerUltimate": "Kills per Ultimate",
+ "averageDroughtTime": "Average Drought Time",
+ "firstPickPercentage": "First Pick %",
+ "firstPicksPer10": "First Picks per 10",
+ "firstDeathPercentage": "First Death %",
+ "firstDeathsPer10": "First Deaths per 10",
+ "fletaDeadliftPercentage": "Fleta Deadlift %",
+ "mvpScore": "MVP Score",
+ "mapMvpRate": "Map MVP Rate",
+ "duelWinratePercentage": "Duel Winrate %",
+ "fightReversalPercentage": "Fight Reversal %",
+ "eliminationsPer10StdDev": "Eliminations Std Dev",
+ "deathsPer10StdDev": "Deaths Std Dev",
+ "allDamagePer10StdDev": "Damage Std Dev",
+ "consistencyScore": "Consistency Score"
+ }
+ },
+ "impactMetrics": {
+ "subtitle": "{count, plural, =1 {Analyzed across 1 map} other {Analyzed across # maps}}",
+ "consistency": "Consistency",
+ "keyStrengths": "Key Strengths",
+ "developmentAreas": "Areas to Consider",
+ "detailedMetrics": "Detailed Impact Metrics",
+ "stats": {
+ "mvpScore": "MVP Score",
+ "firstPickRate": "First Pick Rate",
+ "killsPerUlt": "Kills per Ult",
+ "firstPicks": "First Picks",
+ "firstDeaths": "First Deaths",
+ "duelWinrate": "Duel Winrate",
+ "fightReversal": "Fight Reversal",
+ "fletaDeadlift": "Team Impact",
+ "mapMvpRate": "Map MVP Rate"
+ }
+ },
"views": {
"sideBySide": "Side by Side",
"delta": "Delta",
"trends": "Trends",
"charts": "Charts",
- "consistency": "Consistency"
+ "consistency": "Consistency",
+ "detailedStats": "Detailed Stats",
+ "impactMetrics": "Impact Metrics"
},
"filters": {
"title": "Comparison Filters",
@@ -819,6 +895,21 @@
"performanceProgression": "Performance Progression",
"statComparison": "Stat Comparison",
"performanceProfile": "Performance Profile",
+ "firstPickPercentage": "First Pick %",
+ "firstDeathPercentage": "First Death %",
+ "killsPerUltimate": "Kills per Ultimate",
+ "duelWinrate": "Duel Winrate",
+ "soloKillsPer10": "Solo Kills per 10",
+ "damageTakenPer10": "Damage Taken per 10",
+ "healingReceivedPer10": "Healing Received per 10",
+ "fightReversalPercentage": "Fight Reversal %",
+ "per10": "per 10",
+ "avgUltImpact": "Average ultimate impact",
+ "oneVsOneSuccess": "1v1 success rate",
+ "individualKills": "Individual eliminations",
+ "damageReceived": "Damage received",
+ "supportReceived": "Support received",
+ "clutchPlays": "Clutch performance",
"totalEliminations": "Total Eliminations",
"totalDeaths": "Total Deaths",
"totalDamage": "Total Damage",
@@ -828,6 +919,8 @@
"damage": "Damage (K)",
"healing": "Healing (K)",
"mitigated": "Mitigated (K)",
+ "healingPer10K": "Healing/10 (K)",
+ "blockedPer10K": "Blocked/10 (K)",
"elimsPer10": "Elims/10",
"deathsPer10": "Deaths/10",
"damagePer10K": "Damage/10 (K)",
From 42eb74d01c9e7aa6c6ccc079d3457e35b9af8f21 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:59:26 -0500
Subject: [PATCH 049/103] Enhance ComparisonContent component by adding
detailed stats and impact metrics views, updating available view modes and UI
layout for improved team comparison experience
---
src/components/compare/comparison-content.tsx | 59 ++++++++++++++++---
1 file changed, 52 insertions(+), 7 deletions(-)
diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx
index cfcf80752..c62993237 100644
--- a/src/components/compare/comparison-content.tsx
+++ b/src/components/compare/comparison-content.tsx
@@ -16,7 +16,9 @@ import { ChartsView } from "./charts-view";
import { ComparisonFilters } from "./comparison-filters";
import { ConsistencyView } from "./consistency-view";
import { DeltaView } from "./delta-view";
+import { DetailedStatsView } from "./detailed-stats-view";
import { EmptyState } from "./empty-state";
+import { ImpactMetricsView } from "./impact-metrics-view";
import { SideBySideView } from "./side-by-side-view";
import { TeamComparisonView } from "./team-comparison-view";
import { TrendsView } from "./trends-view";
@@ -26,7 +28,14 @@ type ComparisonContentProps = {
locale: string;
};
-type ViewMode = "side-by-side" | "delta" | "trends" | "charts" | "consistency";
+type ViewMode =
+ | "side-by-side"
+ | "delta"
+ | "trends"
+ | "charts"
+ | "consistency"
+ | "detailed-stats"
+ | "impact-metrics";
type ComparisonMode = "player" | "team";
async function fetchComparisonStats(
@@ -146,11 +155,25 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
}
// Player comparison views
- return selectedMapIds.length === 2
- ? ["side-by-side", "delta", "charts", "consistency"]
- : selectedMapIds.length >= 3
- ? ["trends", "charts", "consistency"]
- : [];
+ if (selectedMapIds.length === 2) {
+ return [
+ "side-by-side",
+ "delta",
+ "charts",
+ "consistency",
+ "detailed-stats",
+ "impact-metrics",
+ ];
+ } else if (selectedMapIds.length >= 3) {
+ return [
+ "trends",
+ "charts",
+ "consistency",
+ "detailed-stats",
+ "impact-metrics",
+ ];
+ }
+ return [];
}, [selectedMapIds.length, comparisonMode]);
// Auto-switch view when map selection or comparison mode changes
@@ -286,7 +309,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
value={activeView}
onValueChange={(v) => setActiveView(v as ViewMode)}
>
-
+
{availableViews.includes("side-by-side") && (
{t("views.sideBySide")}
@@ -306,6 +329,16 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{t("views.consistency")}
)}
+ {availableViews.includes("detailed-stats") && (
+
+ {t("views.detailedStats")}
+
+ )}
+ {availableViews.includes("impact-metrics") && (
+
+ {t("views.impactMetrics")}
+
+ )}
{availableViews.includes("side-by-side") && (
@@ -340,6 +373,18 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
)}
+
+ {availableViews.includes("detailed-stats") && (
+
+
+
+ )}
+
+ {availableViews.includes("impact-metrics") && (
+
+
+
+ )}
)}
From cd6d5ab36b040d0cbb1a34ca4fa9a2b9cc286c61 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:59:36 -0500
Subject: [PATCH 050/103] Add ImpactMetricsView component to display player
performance insights and trends, enhancing the team comparison experience
with detailed metrics and visual indicators
---
.../compare/impact-metrics-view.tsx | 529 ++++++++++++++++++
1 file changed, 529 insertions(+)
create mode 100644 src/components/compare/impact-metrics-view.tsx
diff --git a/src/components/compare/impact-metrics-view.tsx b/src/components/compare/impact-metrics-view.tsx
new file mode 100644
index 000000000..f909eafec
--- /dev/null
+++ b/src/components/compare/impact-metrics-view.tsx
@@ -0,0 +1,529 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import type { ComparisonStats, TrendsAnalysis } from "@/data/comparison-dto";
+import {
+ Award,
+ Crosshair,
+ Flame,
+ Shield,
+ Sparkles,
+ Star,
+ TrendingUp,
+ Trophy,
+ Zap,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type ImpactMetricsViewProps = {
+ stats: ComparisonStats[];
+};
+
+type ImpactInsight = {
+ type: "strength" | "neutral" | "consideration";
+ title: string;
+ description: string;
+ metric?: string;
+ icon: typeof Star;
+};
+
+type TrendDirection = "up" | "down" | "stable" | null;
+
+function getTrendDirection(
+ metricName: string,
+ trends?: TrendsAnalysis
+): { direction: TrendDirection; changePercentage: number } {
+ if (!trends) {
+ return { direction: null, changePercentage: 0 };
+ }
+
+ const improving = trends.improvingMetrics.find((m) =>
+ m.metric.toLowerCase().includes(metricName.toLowerCase())
+ );
+ if (improving) {
+ return { direction: "up", changePercentage: improving.changePercentage };
+ }
+
+ const declining = trends.decliningMetrics.find((m) =>
+ m.metric.toLowerCase().includes(metricName.toLowerCase())
+ );
+ if (declining) {
+ return { direction: "down", changePercentage: declining.changePercentage };
+ }
+
+ return { direction: "stable", changePercentage: 0 };
+}
+
+function TrendIndicator({
+ direction,
+ changePercentage,
+}: {
+ direction: TrendDirection;
+ changePercentage: number;
+}) {
+ if (direction === null || direction === "stable") {
+ return null;
+ }
+
+ const isPositive = direction === "up";
+ const Icon = isPositive ? TrendingUp : TrendingUp;
+
+ return (
+
+
+ {Math.abs(changePercentage).toFixed(0)}%
+
+ );
+}
+
+function getConsistencyLevel(score: number): {
+ label: string;
+ color: string;
+ bgColor: string;
+} {
+ if (score >= 75) {
+ return {
+ label: "High Consistency",
+ color: "text-emerald-700 dark:text-emerald-400",
+ bgColor: "bg-emerald-100 dark:bg-emerald-950/40",
+ };
+ }
+ if (score >= 50) {
+ return {
+ label: "Moderate Consistency",
+ color: "text-amber-700 dark:text-amber-400",
+ bgColor: "bg-amber-100 dark:bg-amber-950/40",
+ };
+ }
+ return {
+ label: "Variable Performance",
+ color: "text-rose-700 dark:text-rose-400",
+ bgColor: "bg-rose-100 dark:bg-rose-950/40",
+ };
+}
+
+function generateInsights(stat: ComparisonStats): ImpactInsight[] {
+ const insights: ImpactInsight[] = [];
+ const { aggregated } = stat;
+
+ // First Pick insights
+ if (aggregated.firstPickPercentage > 30) {
+ insights.push({
+ type: "strength",
+ title: "Strong Fight Initiator",
+ description: `Gets the opening kill in ${aggregated.firstPickPercentage.toFixed(1)}% of fights. This player excels at creating early advantages.`,
+ metric: `${aggregated.firstPickPercentage.toFixed(1)}%`,
+ icon: Crosshair,
+ });
+ }
+
+ // Consistency insights
+ if (aggregated.consistencyScore > 70) {
+ insights.push({
+ type: "strength",
+ title: "Reliable Performer",
+ description:
+ "Maintains consistent performance across different map types and matchups. Low variance in key metrics.",
+ metric: `${aggregated.consistencyScore.toFixed(0)}/100`,
+ icon: TrendingUp,
+ });
+ } else if (aggregated.consistencyScore < 50) {
+ insights.push({
+ type: "consideration",
+ title: "Context-Dependent Performance",
+ description:
+ "Performance varies significantly across maps. Consider map/matchup context when evaluating.",
+ metric: `${aggregated.consistencyScore.toFixed(0)}/100`,
+ icon: TrendingUp,
+ });
+ }
+
+ // Ultimate efficiency
+ if (aggregated.killsPerUltimate > 2.5) {
+ insights.push({
+ type: "strength",
+ title: "High Ultimate Impact",
+ description: `Averages ${aggregated.killsPerUltimate.toFixed(1)} eliminations per ultimate. Makes ult usage count.`,
+ metric: `${aggregated.killsPerUltimate.toFixed(1)} K/Ult`,
+ icon: Zap,
+ });
+ }
+
+ // Fight reversal
+ if (aggregated.fightReversalPercentage > 20) {
+ insights.push({
+ type: "strength",
+ title: "Clutch Performer",
+ description: `Turns around ${aggregated.fightReversalPercentage.toFixed(1)}% of losing fights. Strong under pressure.`,
+ metric: `${aggregated.fightReversalPercentage.toFixed(1)}%`,
+ icon: Shield,
+ });
+ }
+
+ // MVP performance
+ if (aggregated.mapMvpRate > 50) {
+ insights.push({
+ type: "strength",
+ title: "Consistent MVP",
+ description: `Named map MVP in ${aggregated.mapMvpRate.toFixed(1)}% of matches. High overall impact.`,
+ metric: `${aggregated.mapMvpRate.toFixed(1)}%`,
+ icon: Trophy,
+ });
+ }
+
+ // Fleta Deadlift (team impact)
+ if (aggregated.fletaDeadliftPercentage > 30) {
+ insights.push({
+ type: "strength",
+ title: "High Team Impact",
+ description: `Contributes ${aggregated.fletaDeadliftPercentage.toFixed(1)}% of team eliminations. Key damage dealer.`,
+ metric: `${aggregated.fletaDeadliftPercentage.toFixed(1)}%`,
+ icon: Flame,
+ });
+ }
+
+ // First death consideration
+ if (aggregated.firstDeathPercentage > 25) {
+ insights.push({
+ type: "consideration",
+ title: "Dies Early Frequently",
+ description: `First to die in ${aggregated.firstDeathPercentage.toFixed(1)}% of fights. Consider positioning and survival.`,
+ metric: `${aggregated.firstDeathPercentage.toFixed(1)}%`,
+ icon: Crosshair,
+ });
+ }
+
+ return insights;
+}
+
+export function ImpactMetricsView({ stats }: ImpactMetricsViewProps) {
+ const t = useTranslations("comparePage.impactMetrics");
+
+ return (
+
+ {stats.map((playerStat) => {
+ const insights = generateInsights(playerStat);
+ const strengthInsights = insights.filter((i) => i.type === "strength");
+ const considerationInsights = insights.filter(
+ (i) => i.type === "consideration"
+ );
+ const consistency = getConsistencyLevel(
+ playerStat.aggregated.consistencyScore
+ );
+
+ return (
+
+
+
+
+
+ {playerStat.playerName}
+
+
+
+ {t("subtitle", { count: playerStat.mapCount })}
+
+ {playerStat.trends && (
+
+
+ Trends Available
+
+ )}
+
+
+
+
+ {t("consistency")}
+
+
+ {consistency.label}
+
+
+
+
+
+
+ {/* One-glance summary */}
+
+
+
+
+
+
+ {t("stats.mvpScore")}
+
+
+
+
+
+ {playerStat.aggregated.mvpScore.toFixed(1)}
+
+
+
+
+
+
+
+
+
+
+
+ {t("stats.firstPickRate")}
+
+
+
+
+
+ {playerStat.aggregated.firstPickPercentage.toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+
+ {t("stats.killsPerUlt")}
+
+
+
+
+
+ {playerStat.aggregated.killsPerUltimate.toFixed(1)}
+
+
+
+
+
+ {/* Key strengths */}
+ {strengthInsights.length > 0 && (
+
+
+
+
+ {t("keyStrengths")}
+
+
+
+ {strengthInsights.map((insight) => {
+ const Icon = insight.icon;
+ return (
+
+
+
+
+
+
+
+
+ {insight.title}
+
+ {insight.metric && (
+
+ {insight.metric}
+
+ )}
+
+
+ {insight.description}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Development areas */}
+ {considerationInsights.length > 0 && (
+
+
+
+
+ {t("developmentAreas")}
+
+
+
+ {considerationInsights.map((insight) => {
+ const Icon = insight.icon;
+ return (
+
+
+
+
+
+
+
+
+ {insight.title}
+
+ {insight.metric && (
+
+ {insight.metric}
+
+ )}
+
+
+ {insight.description}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Detailed impact metrics grid */}
+
+
+ {t("detailedMetrics")}
+
+
+ {[
+ {
+ label: t("stats.firstPicks"),
+ value: playerStat.aggregated.firstPickPercentage,
+ valuePer10: playerStat.aggregated.firstPicksPer10,
+ format: "percentage" as const,
+ color: "emerald",
+ icon: Crosshair,
+ },
+ {
+ label: t("stats.firstDeaths"),
+ value: playerStat.aggregated.firstDeathPercentage,
+ valuePer10: playerStat.aggregated.firstDeathsPer10,
+ format: "percentage" as const,
+ color: "rose",
+ icon: Shield,
+ reverse: true,
+ },
+ {
+ label: t("stats.fightReversal"),
+ value: playerStat.aggregated.fightReversalPercentage,
+ format: "percentage" as const,
+ color: "blue",
+ icon: Shield,
+ },
+ {
+ label: t("stats.fletaDeadlift"),
+ value: playerStat.aggregated.fletaDeadliftPercentage,
+ format: "percentage" as const,
+ color: "orange",
+ icon: Flame,
+ },
+ {
+ label: t("stats.mapMvpRate"),
+ value: playerStat.aggregated.mapMvpRate,
+ format: "percentage" as const,
+ color: "amber",
+ icon: Trophy,
+ },
+ ].map((metric) => {
+ const trend = getTrendDirection(
+ metric.label,
+ playerStat.trends
+ );
+ return (
+
+
+
+
+
+
+
+ {metric.valuePer10 && (
+
+ {metric.valuePer10.toFixed(1)}/10
+
+ )}
+
+
+
+ {metric.label}
+
+
+ {metric.format === "percentage"
+ ? `${metric.value.toFixed(1)}%`
+ : metric.value.toFixed(1)}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+
+ );
+}
From 5caaf300ff5b52616f4cf29e49b9032be9959401 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 00:59:46 -0500
Subject: [PATCH 051/103] Enhance comparison DTO by adding new aggregated
statistics and logging for better insights into player performance metrics
---
src/data/comparison-dto.ts | 170 +++++++++++++++++++++++++++++++++----
1 file changed, 154 insertions(+), 16 deletions(-)
diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts
index a7ff8eecc..efbe08f65 100644
--- a/src/data/comparison-dto.ts
+++ b/src/data/comparison-dto.ts
@@ -4,6 +4,7 @@ import {
calculateMean,
calculateStandardDeviation,
} from "@/lib/distribution-utils";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { removeDuplicateRows } from "@/lib/utils";
import type { HeroName } from "@/types/heroes";
@@ -56,6 +57,20 @@ export type AggregatedStats = {
damageTakenPer10: number;
damageBlockedPer10: number;
ultimatesEarnedPer10: number;
+ ultimatesUsedPer10: number;
+ soloKillsPer10: number;
+ objectiveKillsPer10: number;
+ defensiveAssistsPer10: number;
+ offensiveAssistsPer10: number;
+ environmentalKillsPer10: number;
+ environmentalDeathsPer10: number;
+ multikillsPer10: number;
+ barrierDamagePer10: number;
+ selfHealingPer10: number;
+ firstPicksPer10: number;
+ firstDeathsPer10: number;
+ mapMvpRate: number;
+ ajaxPer10: number;
weaponAccuracy: number;
criticalHitAccuracy: number;
scopedAccuracy: number;
@@ -160,6 +175,23 @@ function aggregateCalculatedStats(
const counts: Record = {};
+ // Log the stat types we're processing
+ const statTypeCounts: Record = {};
+ stats.forEach((stat) => {
+ statTypeCounts[stat.stat] = (statTypeCounts[stat.stat] ?? 0) + 1;
+ });
+
+ Logger.info({
+ message: "[Comparison] aggregateCalculatedStats",
+ totalStats: stats.length,
+ statTypeCounts,
+ sampleStats: stats.slice(0, 3).map((s) => ({
+ stat: s.stat,
+ value: s.value,
+ hero: s.hero,
+ })),
+ });
+
stats.forEach((stat) => {
switch (stat.stat) {
case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE:
@@ -490,6 +522,57 @@ function aggregatePlayerStats(
totals.ultimatesEarned,
totals.heroTimePlayed
),
+ ultimatesUsedPer10: calculatePer10(
+ totals.ultimatesUsed,
+ totals.heroTimePlayed
+ ),
+ soloKillsPer10: calculatePer10(totals.soloKills, totals.heroTimePlayed),
+ objectiveKillsPer10: calculatePer10(
+ totals.objectiveKills,
+ totals.heroTimePlayed
+ ),
+ defensiveAssistsPer10: calculatePer10(
+ totals.defensiveAssists,
+ totals.heroTimePlayed
+ ),
+ offensiveAssistsPer10: calculatePer10(
+ totals.offensiveAssists,
+ totals.heroTimePlayed
+ ),
+ environmentalKillsPer10: calculatePer10(
+ totals.environmentalKills,
+ totals.heroTimePlayed
+ ),
+ environmentalDeathsPer10: calculatePer10(
+ totals.environmentalDeaths,
+ totals.heroTimePlayed
+ ),
+ multikillsPer10: calculatePer10(totals.multikills, totals.heroTimePlayed),
+ barrierDamagePer10: calculatePer10(
+ totals.barrierDamageDealt,
+ totals.heroTimePlayed
+ ),
+ selfHealingPer10: calculatePer10(totals.selfHealing, totals.heroTimePlayed),
+ firstPicksPer10: calculatePer10(
+ calculatedAggregates.firstPickCount ?? 0,
+ totals.heroTimePlayed
+ ),
+ firstDeathsPer10: calculatePer10(
+ calculatedAggregates.firstDeathCount ?? 0,
+ totals.heroTimePlayed
+ ),
+ mapMvpRate:
+ perMapCalculatedStats && perMapCalculatedStats.length > 0
+ ? ((calculatedAggregates.mapMvpCount ?? 0) /
+ perMapCalculatedStats.length) *
+ 100
+ : stats.length > 0
+ ? ((calculatedAggregates.mapMvpCount ?? 0) / stats.length) * 100
+ : 0,
+ ajaxPer10: calculatePer10(
+ calculatedAggregates.ajaxCount ?? 0,
+ totals.heroTimePlayed
+ ),
weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired),
criticalHitAccuracy: calculatePercentage(
totals.criticalHits,
@@ -577,11 +660,31 @@ function calculateTrends(
late: latePerformance.firstDeathPercentage,
invertImprovement: true,
},
+ {
+ name: "First Pick %",
+ early: earlyPerformance.firstPickPercentage,
+ late: latePerformance.firstPickPercentage,
+ },
{
name: "MVP Score",
early: earlyPerformance.mvpScore,
late: latePerformance.mvpScore,
},
+ {
+ name: "Fight Reversal %",
+ early: earlyPerformance.fightReversalPercentage,
+ late: latePerformance.fightReversalPercentage,
+ },
+ {
+ name: "Fleta Deadlift %",
+ early: earlyPerformance.fletaDeadliftPercentage,
+ late: latePerformance.fletaDeadliftPercentage,
+ },
+ {
+ name: "Kills per Ultimate",
+ early: earlyPerformance.killsPerUltimate,
+ late: latePerformance.killsPerUltimate,
+ },
];
const improvingMetrics: {
@@ -685,9 +788,10 @@ async function getComparisonStatsFn(
`
);
- // CalculatedStat.MapDataId stores actual MapData.id (as per schema)
+ // CalculatedStat.MapDataId likely stores Map.id (not MapData.id), same as PlayerStat
+ // This is confusing naming but matches the PlayerStat pattern
const calculatedStatsWhere: Prisma.CalculatedStatWhereInput = {
- MapDataId: { in: mapDataIds },
+ MapDataId: { in: mapIds }, // Use mapIds, not mapDataIds!
playerName: { equals: playerName, mode: "insensitive" },
...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}),
};
@@ -696,22 +800,28 @@ async function getComparisonStatsFn(
where: calculatedStatsWhere,
});
- // 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);
- }
- }
+ Logger.info({
+ message: "[Comparison] Calculated Stats Query",
+ mapIds, // The Map IDs for reference
+ mapDataIds, // The MapData IDs used in query
+ playerName,
+ heroes,
+ calculatedStatsCount: calculatedStats.length,
+ sampleStats: calculatedStats.slice(0, 3).map((s) => ({
+ stat: s.stat,
+ value: s.value,
+ hero: s.hero,
+ MapDataId: s.MapDataId,
+ })),
+ });
- const calculatedStatsByMapId: Record = {};
+ // Organize calculated stats by their MapDataId value
+ const calculatedStatsByMapDataId: Record = {};
calculatedStats.forEach((stat) => {
- const mapId = mapDataIdToMapId.get(stat.MapDataId);
- if (!mapId) return;
- if (!calculatedStatsByMapId[mapId]) {
- calculatedStatsByMapId[mapId] = [];
+ if (!calculatedStatsByMapDataId[stat.MapDataId]) {
+ calculatedStatsByMapDataId[stat.MapDataId] = [];
}
- calculatedStatsByMapId[mapId].push(stat);
+ calculatedStatsByMapDataId[stat.MapDataId].push(stat);
});
// PlayerStat.MapDataId already contains Map.id, so use it directly
@@ -728,7 +838,13 @@ async function getComparisonStatsFn(
const perMapBreakdown: MapBreakdown[] = [];
for (const map of maps) {
const mapStats = statsByMapId[map.id] || [];
- const mapCalcStats = calculatedStatsByMapId[map.id] || [];
+
+ // CalculatedStat.MapDataId stores MapData.id, so we need to check all mapData for this map
+ const mapCalcStats: CalculatedStat[] = [];
+ for (const md of map.mapData) {
+ const mdStats = calculatedStatsByMapDataId[md.id] || [];
+ mapCalcStats.push(...mdStats);
+ }
if (mapStats.length === 0) continue;
@@ -800,6 +916,19 @@ async function getComparisonStatsFn(
perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime());
+ Logger.info({
+ message: "[Comparison] Before aggregation",
+ finalRoundStatsCount: finalRoundStats.length,
+ calculatedStatsCount: calculatedStats.length,
+ perMapBreakdownCount: perMapBreakdown.length,
+ sampleCalculatedStats: calculatedStats.slice(0, 5).map((s) => ({
+ stat: s.stat,
+ value: s.value,
+ hero: s.hero,
+ playerName: s.playerName,
+ })),
+ });
+
const aggregated = aggregatePlayerStats(
finalRoundStats,
calculatedStats,
@@ -807,6 +936,15 @@ async function getComparisonStatsFn(
perMapBreakdown.map((m) => m.calculatedStats)
);
+ Logger.info({
+ message: "[Comparison] After aggregation",
+ mvpScore: aggregated.mvpScore,
+ mapMvpCount: aggregated.mapMvpCount,
+ mapMvpRate: aggregated.mapMvpRate,
+ firstPickPercentage: aggregated.firstPickPercentage,
+ killsPerUltimate: aggregated.killsPerUltimate,
+ });
+
const trends =
perMapBreakdown.length >= 3
? calculateTrends(
From 67eadb30e79bb6e9497955bf03e871993992e809 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:03:16 -0500
Subject: [PATCH 052/103] Add DetailedStatsView component for comprehensive
player performance analysis
---
.../compare/detailed-stats-view.tsx | 647 ++++++++++++++++++
1 file changed, 647 insertions(+)
create mode 100644 src/components/compare/detailed-stats-view.tsx
diff --git a/src/components/compare/detailed-stats-view.tsx b/src/components/compare/detailed-stats-view.tsx
new file mode 100644
index 000000000..d1ac998dd
--- /dev/null
+++ b/src/components/compare/detailed-stats-view.tsx
@@ -0,0 +1,647 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type {
+ AggregatedStats,
+ ComparisonStats,
+ TrendsAnalysis,
+} from "@/data/comparison-dto";
+import {
+ Activity,
+ Award,
+ ChevronDown,
+ ChevronUp,
+ Download,
+ Eye,
+ Filter,
+ Shield,
+ Sparkles,
+ Sword,
+ Target,
+ TrendingDown,
+ TrendingUp,
+ Zap,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+
+type DetailedStatsViewProps = {
+ stats: ComparisonStats[];
+};
+
+type StatCategory =
+ | "combat"
+ | "damage"
+ | "support"
+ | "defense"
+ | "assists"
+ | "ultimate"
+ | "impact"
+ | "consistency";
+
+type StatRow = {
+ label: string;
+ key: keyof AggregatedStats;
+ format: "number" | "percentage" | "time" | "ratio" | "per10";
+ category: StatCategory;
+ priority?: boolean;
+ tooltip?: string;
+};
+
+function formatStatValue(
+ value: number,
+ format: "number" | "percentage" | "time" | "ratio" | "per10"
+): string {
+ if (format === "time") {
+ const minutes = Math.floor(value / 60);
+ const seconds = Math.floor(value % 60);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ }
+ if (format === "percentage") {
+ return `${value.toFixed(1)}%`;
+ }
+ if (format === "ratio") {
+ return value.toFixed(2);
+ }
+ if (format === "per10") {
+ return value.toFixed(1);
+ }
+ return value.toLocaleString();
+}
+
+function getCategoryIcon(category: StatCategory) {
+ switch (category) {
+ case "combat":
+ return Sword;
+ case "damage":
+ return Target;
+ case "support":
+ return Sparkles;
+ case "defense":
+ return Shield;
+ case "assists":
+ return Activity;
+ case "ultimate":
+ return Zap;
+ case "impact":
+ return Award;
+ case "consistency":
+ return TrendingUp;
+ default:
+ return Eye;
+ }
+}
+
+function getCategoryColor(category: StatCategory): string {
+ switch (category) {
+ case "combat":
+ return "rose";
+ case "damage":
+ return "orange";
+ case "support":
+ return "emerald";
+ case "defense":
+ return "blue";
+ case "assists":
+ return "purple";
+ case "ultimate":
+ return "violet";
+ case "impact":
+ return "amber";
+ case "consistency":
+ return "cyan";
+ default:
+ return "gray";
+ }
+}
+
+function getTrendForMetric(
+ metricKey: string,
+ trends?: TrendsAnalysis
+): { isImproving: boolean; changePercentage: number } | null {
+ if (!trends) return null;
+
+ // Map stat keys to their corresponding trend metric names
+ const metricNameMap: Record = {
+ eliminationsPer10: "Eliminations per 10",
+ deathsPer10: "Deaths per 10",
+ heroDamagePer10: "Damage Dealt per 10",
+ damageTakenPer10: "Damage Taken per 10",
+ firstDeathPercentage: "First Death %",
+ firstPickPercentage: "First Pick %",
+ mvpScore: "MVP Score",
+ fightReversalPercentage: "Fight Reversal %",
+ fletaDeadliftPercentage: "Fleta Deadlift %",
+ killsPerUltimate: "Kills per Ultimate",
+ };
+
+ const trendMetricName = metricNameMap[metricKey];
+ if (!trendMetricName) return null;
+
+ const improving = trends.improvingMetrics.find(
+ (m) => m.metric === trendMetricName
+ );
+ if (improving) {
+ return { isImproving: true, changePercentage: improving.changePercentage };
+ }
+
+ const declining = trends.decliningMetrics.find(
+ (m) => m.metric === trendMetricName
+ );
+ if (declining) {
+ return { isImproving: false, changePercentage: declining.changePercentage };
+ }
+
+ return null;
+}
+
+export function DetailedStatsView({ stats }: DetailedStatsViewProps) {
+ const t = useTranslations("comparePage.detailedStats");
+ const [selectedCategory, setSelectedCategory] = useState<
+ StatCategory | "all"
+ >("all");
+ const [sortBy, setSortBy] = useState<{
+ playerIndex: number;
+ direction: "asc" | "desc";
+ } | null>(null);
+
+ const statDefinitions: StatRow[] = [
+ // Combat
+ {
+ label: t("stats.eliminationsPer10"),
+ key: "eliminationsPer10",
+ format: "per10",
+ category: "combat",
+ priority: true,
+ },
+ {
+ label: t("stats.finalBlowsPer10"),
+ key: "finalBlowsPer10",
+ format: "per10",
+ category: "combat",
+ priority: true,
+ },
+ {
+ label: t("stats.soloKillsPer10"),
+ key: "soloKillsPer10",
+ format: "per10",
+ category: "combat",
+ priority: true,
+ },
+ {
+ label: t("stats.deathsPer10"),
+ key: "deathsPer10",
+ format: "per10",
+ category: "combat",
+ priority: true,
+ },
+
+ // Damage
+ {
+ label: t("stats.allDamagePer10"),
+ key: "allDamagePer10",
+ format: "per10",
+ category: "damage",
+ },
+ {
+ label: t("stats.heroDamagePer10"),
+ key: "heroDamagePer10",
+ format: "per10",
+ category: "damage",
+ },
+ {
+ label: t("stats.barrierDamagePer10"),
+ key: "barrierDamagePer10",
+ format: "per10",
+ category: "damage",
+ },
+
+ // Support
+ {
+ label: t("stats.healingDealtPer10"),
+ key: "healingDealtPer10",
+ format: "per10",
+ category: "support",
+ },
+ {
+ label: t("stats.healingReceivedPer10"),
+ key: "healingReceivedPer10",
+ format: "per10",
+ category: "support",
+ priority: true,
+ },
+ {
+ label: t("stats.selfHealingPer10"),
+ key: "selfHealingPer10",
+ format: "per10",
+ category: "support",
+ },
+
+ // Defense
+ {
+ label: t("stats.damageTakenPer10"),
+ key: "damageTakenPer10",
+ format: "per10",
+ category: "defense",
+ priority: true,
+ },
+ {
+ label: t("stats.damageBlockedPer10"),
+ key: "damageBlockedPer10",
+ format: "per10",
+ category: "defense",
+ },
+
+ // Assists
+ {
+ label: t("stats.offensiveAssistsPer10"),
+ key: "offensiveAssistsPer10",
+ format: "per10",
+ category: "assists",
+ },
+ {
+ label: t("stats.defensiveAssistsPer10"),
+ key: "defensiveAssistsPer10",
+ format: "per10",
+ category: "assists",
+ },
+
+ // Ultimate
+ {
+ label: t("stats.ultimatesEarnedPer10"),
+ key: "ultimatesEarnedPer10",
+ format: "per10",
+ category: "ultimate",
+ },
+ {
+ label: t("stats.ultimatesUsedPer10"),
+ key: "ultimatesUsedPer10",
+ format: "per10",
+ category: "ultimate",
+ },
+ {
+ label: t("stats.averageUltChargeTime"),
+ key: "averageUltChargeTime",
+ format: "time",
+ category: "ultimate",
+ priority: true,
+ },
+ {
+ label: t("stats.averageTimeToUseUlt"),
+ key: "averageTimeToUseUlt",
+ format: "time",
+ category: "ultimate",
+ },
+ {
+ label: t("stats.killsPerUltimate"),
+ key: "killsPerUltimate",
+ format: "ratio",
+ category: "ultimate",
+ priority: true,
+ },
+ {
+ label: t("stats.averageDroughtTime"),
+ key: "averageDroughtTime",
+ format: "time",
+ category: "ultimate",
+ },
+
+ // Impact
+ {
+ label: t("stats.firstPickPercentage"),
+ key: "firstPickPercentage",
+ format: "percentage",
+ category: "impact",
+ priority: true,
+ },
+ {
+ label: t("stats.firstPicksPer10"),
+ key: "firstPicksPer10",
+ format: "per10",
+ category: "impact",
+ priority: true,
+ },
+ {
+ label: t("stats.firstDeathPercentage"),
+ key: "firstDeathPercentage",
+ format: "percentage",
+ category: "impact",
+ priority: true,
+ },
+ {
+ label: t("stats.firstDeathsPer10"),
+ key: "firstDeathsPer10",
+ format: "per10",
+ category: "impact",
+ priority: true,
+ },
+ {
+ label: t("stats.fletaDeadliftPercentage"),
+ key: "fletaDeadliftPercentage",
+ format: "percentage",
+ category: "impact",
+ },
+ {
+ label: t("stats.mvpScore"),
+ key: "mvpScore",
+ format: "number",
+ category: "impact",
+ },
+ {
+ label: t("stats.mapMvpRate"),
+ key: "mapMvpRate",
+ format: "percentage",
+ category: "impact",
+ },
+ {
+ label: t("stats.fightReversalPercentage"),
+ key: "fightReversalPercentage",
+ format: "percentage",
+ category: "impact",
+ priority: true,
+ },
+
+ // Consistency
+ {
+ label: t("stats.eliminationsPer10StdDev"),
+ key: "eliminationsPer10StdDev",
+ format: "number",
+ category: "consistency",
+ },
+ {
+ label: t("stats.deathsPer10StdDev"),
+ key: "deathsPer10StdDev",
+ format: "number",
+ category: "consistency",
+ },
+ {
+ label: t("stats.allDamagePer10StdDev"),
+ key: "allDamagePer10StdDev",
+ format: "number",
+ category: "consistency",
+ },
+ {
+ label: t("stats.consistencyScore"),
+ key: "consistencyScore",
+ format: "number",
+ category: "consistency",
+ },
+ ];
+
+ const filteredStats =
+ selectedCategory === "all"
+ ? statDefinitions
+ : statDefinitions.filter((stat) => stat.category === selectedCategory);
+
+ const sortedStats = sortBy
+ ? [...filteredStats].sort((a, b) => {
+ const aValue = stats[sortBy.playerIndex].aggregated[a.key];
+ const bValue = stats[sortBy.playerIndex].aggregated[b.key];
+ return sortBy.direction === "asc" ? aValue - bValue : bValue - aValue;
+ })
+ : filteredStats;
+
+ function handleExportCSV() {
+ const headers = ["Stat", ...stats.map((s) => s.playerName)];
+ const rows = filteredStats.map((stat) => [
+ stat.label,
+ ...stats.map((s) => formatStatValue(s.aggregated[stat.key], stat.format)),
+ ]);
+
+ const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "comparison-stats.csv";
+ a.click();
+ }
+
+ const categories: { id: StatCategory | "all"; labelKey: string }[] = [
+ { id: "all", labelKey: "categories.all" },
+ { id: "combat", labelKey: "categories.combat" },
+ { id: "damage", labelKey: "categories.damage" },
+ { id: "support", labelKey: "categories.support" },
+ { id: "defense", labelKey: "categories.defense" },
+ { id: "assists", labelKey: "categories.assists" },
+ { id: "ultimate", labelKey: "categories.ultimate" },
+ { id: "impact", labelKey: "categories.impact" },
+ { id: "consistency", labelKey: "categories.consistency" },
+ ];
+
+ return (
+
+ {/* Header with controls */}
+
+
+
+
+
+ {t("title")}
+
+
+ {t("subtitle")}
+
+
+
+
+
+
+
+ {categories.map((cat) => {
+ const Icon = cat.id === "all" ? Filter : getCategoryIcon(cat.id);
+ const isSelected = selectedCategory === cat.id;
+
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Stats table */}
+
+
+
+
+
+
+
+ {t("statName")}
+
+ {stats.map((stat, index) => (
+
+ setSortBy({
+ playerIndex: index,
+ direction:
+ sortBy?.playerIndex === index &&
+ sortBy.direction === "desc"
+ ? "asc"
+ : "desc",
+ })
+ }
+ >
+
+ {stat.playerName}
+ {sortBy?.playerIndex === index && (
+
+ {sortBy.direction === "desc" ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {stat.mapCount} {t("maps")}
+
+
+ ))}
+
+
+
+ {sortedStats.map((stat) => {
+ const Icon = getCategoryIcon(stat.category);
+ const color = getCategoryColor(stat.category);
+ const values = stats.map((s) => s.aggregated[stat.key]);
+ const maxValue = Math.max(...values);
+ const minValue = Math.min(...values);
+
+ return (
+
+
+
+
+
+
+
+
+ {stat.label}
+ {stat.priority && (
+
+ {t("priority")}
+
+ )}
+
+ {stat.tooltip && (
+
+ {stat.tooltip}
+
+ )}
+
+
+
+ {values.map((value, playerIndex) => {
+ const isMax = value === maxValue && values.length > 1;
+ const isMin = value === minValue && values.length > 1;
+ const trend = getTrendForMetric(
+ stat.key,
+ stats[playerIndex].trends
+ );
+
+ return (
+
+
+ {formatStatValue(value, stat.format)}
+ {trend &&
+ Math.abs(trend.changePercentage) > 5 && (
+
+ {trend.isImproving ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+
+ {/* Context note */}
+
+
+
+ {t("note")}
+
+
+
+ = Improving over time
+ •
+
+ = Declining over time (requires 3+ maps)
+
+
+
+
+ );
+}
From fb16214c4451fd0e72e3ca279e70f71bf34efb99 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:11:15 -0500
Subject: [PATCH 053/103] Refactor TabsList in ComparisonContent component for
improved layout and responsiveness
---
src/components/compare/comparison-content.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx
index c62993237..8f75832a3 100644
--- a/src/components/compare/comparison-content.tsx
+++ b/src/components/compare/comparison-content.tsx
@@ -309,7 +309,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
value={activeView}
onValueChange={(v) => setActiveView(v as ViewMode)}
>
-
+
{availableViews.includes("side-by-side") && (
{t("views.sideBySide")}
From ebf7653e7f8e02edbb6deda340328d6feff26e55 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:20:28 -0500
Subject: [PATCH 054/103] Add unit tests for variance and standard deviation
calculations, including edge cases and consistency score metrics
---
test/variance-calculations.test.ts | 171 +++++++++++++++++++++++++++++
1 file changed, 171 insertions(+)
create mode 100644 test/variance-calculations.test.ts
diff --git a/test/variance-calculations.test.ts b/test/variance-calculations.test.ts
new file mode 100644
index 000000000..5232ce90f
--- /dev/null
+++ b/test/variance-calculations.test.ts
@@ -0,0 +1,171 @@
+import {
+ calculateMean,
+ calculateStandardDeviation,
+} from "@/lib/distribution-utils";
+import { describe, expect, it } from "vitest";
+
+describe("Variance and Standard Deviation Calculations", () => {
+ describe("calculateMean", () => {
+ it("should calculate mean of positive numbers", () => {
+ const values = [10, 20, 30, 40, 50];
+ expect(calculateMean(values)).toBe(30);
+ });
+
+ it("should handle single value", () => {
+ const values = [42];
+ expect(calculateMean(values)).toBe(42);
+ });
+
+ it("should return 0 for empty array", () => {
+ const values: number[] = [];
+ expect(calculateMean(values)).toBe(0);
+ });
+
+ it("should handle decimal values", () => {
+ const values = [1.5, 2.5, 3.5];
+ expect(calculateMean(values)).toBeCloseTo(2.5);
+ });
+
+ it("should handle negative numbers", () => {
+ const values = [-10, -20, -30];
+ expect(calculateMean(values)).toBe(-20);
+ });
+ });
+
+ describe("calculateStandardDeviation", () => {
+ it("should calculate standard deviation correctly", () => {
+ const values = [2, 4, 4, 4, 5, 5, 7, 9];
+ // Expected std dev: 2
+ expect(calculateStandardDeviation(values)).toBeCloseTo(2, 1);
+ });
+
+ it("should return 0 for identical values (no variance)", () => {
+ const values = [5, 5, 5, 5, 5];
+ expect(calculateStandardDeviation(values)).toBe(0);
+ });
+
+ it("should return 0 for single value", () => {
+ const values = [42];
+ expect(calculateStandardDeviation(values)).toBe(0);
+ });
+
+ it("should return 0 for empty array", () => {
+ const values: number[] = [];
+ expect(calculateStandardDeviation(values)).toBe(0);
+ });
+
+ it("should handle decimal values", () => {
+ const values = [1.5, 2.5, 3.5, 4.5, 5.5];
+ // Mean = 3.5, variance = 2, std dev = sqrt(2) ≈ 1.414
+ expect(calculateStandardDeviation(values)).toBeCloseTo(1.414, 2);
+ });
+
+ it("should calculate high variance correctly", () => {
+ const values = [1, 100];
+ // Mean = 50.5, variance = 2450.25, std dev ≈ 49.5
+ expect(calculateStandardDeviation(values)).toBeCloseTo(49.5, 1);
+ });
+ });
+
+ describe("Consistency Score Calculation", () => {
+ it("should calculate coefficient of variation", () => {
+ // CV = (std dev / mean) * 100
+ const values = [10, 20, 30, 40, 50];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+ const cv = (stdDev / mean) * 100;
+
+ expect(mean).toBe(30);
+ expect(stdDev).toBeCloseTo(14.14, 1);
+ expect(cv).toBeCloseTo(47.14, 1);
+ });
+
+ it("should show low CV for consistent performance", () => {
+ // Consistent player: small variation
+ const values = [18, 19, 20, 19, 18];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+ const cv = (stdDev / mean) * 100;
+
+ expect(cv).toBeLessThan(10); // Less than 10% variation
+ });
+
+ it("should show high CV for inconsistent performance", () => {
+ // Inconsistent player: large variation
+ const values = [5, 30, 10, 35, 15];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+ const cv = (stdDev / mean) * 100;
+
+ expect(cv).toBeGreaterThan(50); // More than 50% variation
+ });
+
+ it("should handle zero mean gracefully", () => {
+ const values = [0, 0, 0];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+
+ expect(mean).toBe(0);
+ expect(stdDev).toBe(0);
+ // CV would be 0/0, should handle this edge case
+ });
+ });
+
+ describe("Per-10 Statistics Variance", () => {
+ it("should calculate variance for per-10 stats", () => {
+ // Simulating eliminations per 10 across different maps
+ const elimsPer10 = [15.2, 18.6, 14.8, 17.1, 16.3];
+
+ const mean = calculateMean(elimsPer10);
+ const stdDev = calculateStandardDeviation(elimsPer10);
+
+ expect(mean).toBeCloseTo(16.4, 1);
+ expect(stdDev).toBeCloseTo(1.367, 2);
+ });
+
+ it("should identify highly consistent per-10 performance", () => {
+ // Very consistent player
+ const elimsPer10 = [20.1, 20.3, 19.9, 20.2, 20.0];
+ const stdDev = calculateStandardDeviation(elimsPer10);
+
+ expect(stdDev).toBeLessThan(0.2);
+ });
+
+ it("should identify highly variable per-10 performance", () => {
+ // Very inconsistent player
+ const elimsPer10 = [10, 25, 12, 28, 15];
+ const stdDev = calculateStandardDeviation(elimsPer10);
+
+ expect(stdDev).toBeGreaterThan(6);
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle very large numbers", () => {
+ const values = [1000000, 2000000, 3000000];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+
+ expect(mean).toBe(2000000);
+ expect(stdDev).toBeCloseTo(816496.58, 0);
+ });
+
+ it("should handle very small decimal numbers", () => {
+ const values = [0.001, 0.002, 0.003];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+
+ expect(mean).toBeCloseTo(0.002, 3);
+ expect(stdDev).toBeCloseTo(0.0008, 4);
+ });
+
+ it("should handle mix of positive and negative", () => {
+ const values = [-5, 0, 5];
+ const mean = calculateMean(values);
+ const stdDev = calculateStandardDeviation(values);
+
+ expect(mean).toBe(0);
+ expect(stdDev).toBeCloseTo(4.08, 1);
+ });
+ });
+});
From 06d77837e031ab35242e462527676500dafc446a Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:27:23 -0500
Subject: [PATCH 055/103] Implement dropdown menu and dialog for map group
management in CompareSelectedButton component
---
.../scrim/compare-selected-button.tsx | 78 ++++++++++++++-----
1 file changed, 60 insertions(+), 18 deletions(-)
diff --git a/src/components/scrim/compare-selected-button.tsx b/src/components/scrim/compare-selected-button.tsx
index 8482e006e..a24c62b7f 100644
--- a/src/components/scrim/compare-selected-button.tsx
+++ b/src/components/scrim/compare-selected-button.tsx
@@ -1,6 +1,14 @@
"use client";
+import { AddToMapGroupDialog } from "@/components/scrim/add-to-map-group-dialog";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import {
mapSelectionStore,
selectHasSelections,
@@ -9,10 +17,11 @@ import {
selectUniqueScrimCount,
} from "@/stores/map-selection-store";
import { useSelector } from "@xstate/store/react";
+import { ChevronDown, FolderPlus } from "lucide-react";
import type { Route } from "next";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useState } from "react";
type CompareSelectedButtonProps = {
teamId: number;
@@ -21,6 +30,7 @@ type CompareSelectedButtonProps = {
export function CompareSelectedButton({ teamId }: CompareSelectedButtonProps) {
const t = useTranslations("scrimPage.compareButton");
const router = useRouter();
+ const [isMapGroupDialogOpen, setIsMapGroupDialogOpen] = useState(false);
// Memoize selector functions
const hasSelectionsSelector = useCallback(
@@ -74,28 +84,60 @@ export function CompareSelectedButton({ teamId }: CompareSelectedButtonProps) {
mapSelectionStore.send({ type: "clearAll" });
}, []);
+ const handleAddToMapGroup = useCallback(() => {
+ setIsMapGroupDialogOpen(true);
+ }, []);
+
if (!hasSelections) return null;
return (
-
-
-
-
- {t("selected", { count: selectionCount })}
-
- {uniqueScrimCount > 1 && (
-
- {t("fromScrims", { count: uniqueScrimCount })}
+ <>
+
+
+
+
+
+ {t("selected", { count: selectionCount })}
- )}
+ {uniqueScrimCount > 1 && (
+
+ {t("fromScrims", { count: uniqueScrimCount })}
+
+ )}
+
+
+
+
+
+
+
+
+ {t("compareNow")}
+
+
+
+ {t("addToMapGroup")}
+
+
+
+ {t("clear")}
+
+
+
-
-
-
+ >
);
}
From 564e5967f704df0caba380b9a4a985d73f89ed3e Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:31:11 -0500
Subject: [PATCH 056/103] Add MapGroupsPage component for managing map groups,
including user authentication and team access verification
---
src/app/[team]/map-groups/page.tsx | 101 +++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
create mode 100644 src/app/[team]/map-groups/page.tsx
diff --git a/src/app/[team]/map-groups/page.tsx b/src/app/[team]/map-groups/page.tsx
new file mode 100644
index 000000000..0f805beb9
--- /dev/null
+++ b/src/app/[team]/map-groups/page.tsx
@@ -0,0 +1,101 @@
+import { MapGroupManager } from "@/components/compare/map-group-manager";
+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]/map-groups">
+): Promise {
+ const params = await props.params;
+ const t = await getTranslations({
+ locale: params.locale,
+ namespace: "mapGroupsPage.metadata",
+ });
+
+ return {
+ title: t("title"),
+ description: t("description"),
+ };
+}
+
+export default async function MapGroupsPage(
+ props: PagePropsWithLocale<"/[team]/map-groups">
+) {
+ 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();
+ }
+
+ // Fetch all maps for this team (via Scrim relationship)
+ const maps = await prisma.map.findMany({
+ where: {
+ Scrim: {
+ teamId,
+ },
+ },
+ include: {
+ Scrim: {
+ select: {
+ id: true,
+ name: true,
+ date: true,
+ },
+ },
+ },
+ orderBy: [{ Scrim: { date: "desc" } }, { id: "asc" }],
+ });
+
+ const availableMaps = maps
+ .filter((map) => map.Scrim !== null)
+ .map((map) => ({
+ id: map.id,
+ name: map.name,
+ scrimName: map.Scrim!.name,
+ scrimDate: map.Scrim!.date,
+ }));
+
+ return (
+
+
+
+ );
+}
From 7582fc5f00539132ec51081a48570dbdce057a20 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:36:45 -0500
Subject: [PATCH 057/103] Add error bars to line charts in ChartsView
---
src/components/compare/charts-view.tsx | 168 +++++++++++++++++++++++--
1 file changed, 155 insertions(+), 13 deletions(-)
diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx
index 3e0ad7cbd..cde5337d0 100644
--- a/src/components/compare/charts-view.tsx
+++ b/src/components/compare/charts-view.tsx
@@ -15,6 +15,7 @@ import {
Bar,
BarChart,
CartesianGrid,
+ ErrorBar,
Line,
LineChart,
PolarAngleAxis,
@@ -31,16 +32,140 @@ type ChartsViewProps = {
viewMode: "two-map" | "multi-map";
};
+type CustomTooltipProps = {
+ active?: boolean;
+ payload?: unknown[];
+ label?: string;
+ config?: ChartConfig;
+};
+
+/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
+function CustomLineChartTooltip({
+ active,
+ payload,
+ config,
+}: CustomTooltipProps) {
+ const t = useTranslations("comparePage.charts");
+
+ if (!active || !payload || payload.length === 0) {
+ return null;
+ }
+
+ const firstItem = payload[0] as any;
+ const data = firstItem?.payload;
+
+ if (!data) {
+ return null;
+ }
+
+ return (
+
+
{data.fullName}
+
+ {payload.map((item) => {
+ const entry = item as any;
+
+ if (!entry.dataKey || typeof entry.value !== "number") {
+ return null;
+ }
+
+ const dataKey = String(entry.dataKey);
+ const stdDevKey = `${dataKey}StdDev`;
+ const stdDev = data[stdDevKey];
+ const displayLabel =
+ config?.[dataKey]?.label ?? entry.name ?? dataKey;
+
+ return (
+
+
+
+
+ {displayLabel}: {entry.value}
+
+
+ {typeof stdDev === "number" && stdDev > 0 && (
+
+ {t("stdDev")}: ±{stdDev}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
+
export function ChartsView({ stats, viewMode }: ChartsViewProps) {
const t = useTranslations("comparePage.charts");
- // Prepare data for line chart (multi-map progression)
+ // Prepare data for line chart (multi-map progression) with error bars
const lineChartData = stats.perMapBreakdown.map((map) => ({
name: `${map.scrimName} - ${map.mapName}`,
fullName: `${map.scrimName} - ${map.mapName}`,
elimsPer10: Number((map.stats.eliminationsPer10 ?? 0).toFixed(2)),
+ elimsPer10StdDev: Number(
+ (stats.aggregated.eliminationsPer10StdDev ?? 0).toFixed(2)
+ ),
+ elimsPer10Error: [
+ Number(
+ Math.max(
+ 0,
+ (map.stats.eliminationsPer10 ?? 0) -
+ (stats.aggregated.eliminationsPer10StdDev ?? 0)
+ ).toFixed(2)
+ ),
+ Number(
+ (
+ (map.stats.eliminationsPer10 ?? 0) +
+ (stats.aggregated.eliminationsPer10StdDev ?? 0)
+ ).toFixed(2)
+ ),
+ ],
deathsPer10: Number((map.stats.deathsPer10 ?? 0).toFixed(2)),
+ deathsPer10StdDev: Number(
+ (stats.aggregated.deathsPer10StdDev ?? 0).toFixed(2)
+ ),
+ deathsPer10Error: [
+ Number(
+ Math.max(
+ 0,
+ (map.stats.deathsPer10 ?? 0) -
+ (stats.aggregated.deathsPer10StdDev ?? 0)
+ ).toFixed(2)
+ ),
+ Number(
+ (
+ (map.stats.deathsPer10 ?? 0) +
+ (stats.aggregated.deathsPer10StdDev ?? 0)
+ ).toFixed(2)
+ ),
+ ],
damagePer10: Number(((map.stats.allDamagePer10 ?? 0) / 1000).toFixed(2)), // Scale for better visualization
+ damagePer10StdDev: Number(
+ ((stats.aggregated.allDamagePer10StdDev ?? 0) / 1000).toFixed(2)
+ ),
+ damagePer10Error: [
+ Number(
+ Math.max(
+ 0,
+ ((map.stats.allDamagePer10 ?? 0) -
+ (stats.aggregated.allDamagePer10StdDev ?? 0)) /
+ 1000
+ ).toFixed(2)
+ ),
+ Number(
+ (
+ ((map.stats.allDamagePer10 ?? 0) +
+ (stats.aggregated.allDamagePer10StdDev ?? 0)) /
+ 1000
+ ).toFixed(2)
+ ),
+ ],
}));
const lineChartConfig: ChartConfig = {
@@ -253,16 +378,12 @@ export function ChartsView({ stats, viewMode }: ChartsViewProps) {
/>
{
- const item = payload?.[0]?.payload as
- | { fullName?: string }
- | undefined;
- return item?.fullName ?? "";
- }}
+ content={(props) => (
+
- }
+ )}
/>
} />
+ >
+
+
+ >
+
+
+ >
+
+
From 52362fd581aaf061f5188b3fed4a81b8be401f04 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:40:02 -0500
Subject: [PATCH 058/103] Add map group dialog functionality to
MapCardWithSelection component
---
.../scrim/map-card-with-selection.tsx | 152 ++++++++++--------
1 file changed, 85 insertions(+), 67 deletions(-)
diff --git a/src/components/scrim/map-card-with-selection.tsx b/src/components/scrim/map-card-with-selection.tsx
index 56337475e..7aa901ca3 100644
--- a/src/components/scrim/map-card-with-selection.tsx
+++ b/src/components/scrim/map-card-with-selection.tsx
@@ -1,5 +1,6 @@
"use client";
+import { AddToMapGroupDialog } from "@/components/scrim/add-to-map-group-dialog";
import { ReplayCode } from "@/components/scrim/replay-code";
import { Badge } from "@/components/ui/badge";
import {
@@ -28,7 +29,7 @@ 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 { memo, useCallback, useState } from "react";
import { toast } from "sonner";
type MapCardWithSelectionProps = {
@@ -44,6 +45,7 @@ function MapCardWithSelectionComponent({
teamId,
}: MapCardWithSelectionProps) {
const t = useTranslations("scrimPage.mapCard");
+ const [isMapGroupDialogOpen, setIsMapGroupDialogOpen] = useState(false);
// Memoize selector function
const isSelectedSelector = useCallback(
@@ -79,78 +81,94 @@ function MapCardWithSelectionComponent({
const mapNames = useMapNames();
const displayName = mapNames.get(toKebabCase(map.name)) ?? map.name;
+ const handleAddToMapGroup = useCallback(() => {
+ setIsMapGroupDialogOpen(true);
+ }, []);
+
return (
-
-
-
-
+
+
+
+
-
-
- {displayName}
-
-
-
-
-
-
-
-
- {map.replayCode && }
-
-
+
+
+
+ {displayName}
+
+
+
+
+
+
+
+
+ {map.replayCode && }
+
+
- {/* Selection indicator badge */}
- {isSelected && (
-
- {t("selected")}
-
- )}
-
-
+ {/* Selection indicator badge */}
+ {isSelected && (
+
+ {t("selected")}
+
+ )}
+
+
-
- {t("contextMenu.title")}
-
-
- {t("contextMenu.selectForComparison")}
-
-
-
-
+ {t("contextMenu.title")}
+
+
- {t("contextMenu.viewDetails")}
-
-
- {map.replayCode && (
-
- {t("contextMenu.copyCode")}
+ {t("contextMenu.selectForComparison")}
+
+
+ {t("contextMenu.addToMapGroup")}
- )}
-
-
+
+
+
+ {t("contextMenu.viewDetails")}
+
+
+ {map.replayCode && (
+
+ {t("contextMenu.copyCode")}
+
+ )}
+
+
+ >
);
}
From 4d176cf638090c6d904c696e3c9c28e1dee50a48 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:40:15 -0500
Subject: [PATCH 059/103] Enhance ComparisonContent component with map
selection mode functionality
---
src/components/compare/comparison-content.tsx | 191 ++++++++++++++++--
1 file changed, 169 insertions(+), 22 deletions(-)
diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx
index 8f75832a3..c2283a327 100644
--- a/src/components/compare/comparison-content.tsx
+++ b/src/components/compare/comparison-content.tsx
@@ -6,10 +6,13 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ComparisonStats } from "@/data/comparison-dto";
import type { HeroName } from "@/types/heroes";
+import type { FormattedMapGroup } from "@/types/map-group";
import type { TeamComparisonStats } from "@/types/team-comparison";
import { useQuery } from "@tanstack/react-query";
-import { Loader2 } from "lucide-react";
+import { FolderCog, Loader2 } from "lucide-react";
+import type { Route } from "next";
import { useTranslations } from "next-intl";
+import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ChartsView } from "./charts-view";
@@ -19,6 +22,7 @@ import { DeltaView } from "./delta-view";
import { DetailedStatsView } from "./detailed-stats-view";
import { EmptyState } from "./empty-state";
import { ImpactMetricsView } from "./impact-metrics-view";
+import { MapGroupSelector } from "./map-group-selector";
import { SideBySideView } from "./side-by-side-view";
import { TeamComparisonView } from "./team-comparison-view";
import { TrendsView } from "./trends-view";
@@ -37,6 +41,7 @@ type ViewMode =
| "detailed-stats"
| "impact-metrics";
type ComparisonMode = "player" | "team";
+type MapSelectionMode = "individual" | "groups";
async function fetchComparisonStats(
mapIds: number[],
@@ -86,15 +91,27 @@ async function fetchTeamComparisonStats(
return data.data;
}
+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;
+}
+
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))
- : [];
+ // Get map IDs from URL - memoize to prevent useMemo dependency issues
+ const selectedMapIds = useMemo(() => {
+ const mapsParam = searchParams.get("maps");
+ return mapsParam ? mapsParam.split(",").map((id) => parseInt(id, 10)) : [];
+ }, [searchParams]);
// Filter state
const [selectedPlayer, setSelectedPlayer] = useState(null);
@@ -107,40 +124,75 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
const [comparisonMode, setComparisonMode] =
useState("player");
+ // Map selection mode state (individual maps vs map groups)
+ const [mapSelectionMode, setMapSelectionMode] =
+ useState("individual");
+ const [selectedMapGroups, setSelectedMapGroups] = useState([]);
+
// View mode state
const [activeView, setActiveView] = useState("side-by-side");
+ // Fetch map groups to resolve group IDs to map IDs
+ const { data: mapGroups } = useQuery({
+ queryKey: ["mapGroups", teamId],
+ queryFn: () => fetchMapGroups(teamId),
+ staleTime: 5 * 60 * 1000,
+ enabled: mapSelectionMode === "groups",
+ });
+
+ // Calculate effective map IDs based on selection mode
+ const effectiveMapIds = useMemo(() => {
+ if (mapSelectionMode === "individual") {
+ return selectedMapIds;
+ }
+
+ // In groups mode, combine all map IDs from selected groups
+ if (!mapGroups || selectedMapGroups.length === 0) {
+ return [];
+ }
+
+ const mapIdsSet = new Set();
+ selectedMapGroups.forEach((groupId) => {
+ const group = mapGroups.find((g) => g.id === groupId);
+ if (group) {
+ group.mapIds.forEach((mapId) => mapIdsSet.add(mapId));
+ }
+ });
+
+ return Array.from(mapIdsSet);
+ }, [mapSelectionMode, selectedMapIds, selectedMapGroups, mapGroups]);
+
// Fetch comparison stats (player mode)
const { data: comparisonStats, isLoading: isLoadingPlayer } = useQuery({
queryKey: [
"comparisonStats",
- selectedMapIds,
+ effectiveMapIds,
selectedPlayer,
selectedHeroes,
],
queryFn: () =>
fetchComparisonStats(
- selectedMapIds,
+ effectiveMapIds,
selectedPlayer!,
selectedHeroes.length > 0 ? selectedHeroes : undefined
),
enabled:
comparisonMode === "player" &&
- selectedMapIds.length > 0 &&
+ effectiveMapIds.length > 0 &&
!!selectedPlayer,
staleTime: 5 * 60 * 1000,
});
// Fetch team comparison stats (team mode)
const { data: teamComparisonStats, isLoading: isLoadingTeam } = useQuery({
- queryKey: ["teamComparisonStats", selectedMapIds, teamId, selectedHeroes],
+ queryKey: ["teamComparisonStats", effectiveMapIds, teamId, selectedHeroes],
queryFn: () =>
fetchTeamComparisonStats(
- selectedMapIds,
+ effectiveMapIds,
teamId,
selectedHeroes.length > 0 ? selectedHeroes : undefined
),
- enabled: comparisonMode === "team" && selectedMapIds.length > 0,
+ enabled: comparisonMode === "team" && effectiveMapIds.length > 0,
staleTime: 5 * 60 * 1000,
});
@@ -151,11 +203,11 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
const availableViews: ViewMode[] = useMemo(() => {
// Team comparison doesn't support all views
if (comparisonMode === "team") {
- return selectedMapIds.length >= 2 ? ["side-by-side"] : [];
+ return effectiveMapIds.length >= 2 ? ["side-by-side"] : [];
}
// Player comparison views
- if (selectedMapIds.length === 2) {
+ if (effectiveMapIds.length === 2) {
return [
"side-by-side",
"delta",
@@ -164,7 +216,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
"detailed-stats",
"impact-metrics",
];
- } else if (selectedMapIds.length >= 3) {
+ } else if (effectiveMapIds.length >= 3) {
return [
"trends",
"charts",
@@ -174,7 +226,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
];
}
return [];
- }, [selectedMapIds.length, comparisonMode]);
+ }, [effectiveMapIds.length, comparisonMode]);
// Auto-switch view when map selection or comparison mode changes
useEffect(() => {
@@ -183,8 +235,8 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
}
}, [availableViews, activeView]);
- // Empty state when no maps selected
- if (selectedMapIds.length === 0) {
+ // Empty state when no maps selected (considering both modes)
+ if (effectiveMapIds.length === 0) {
return (
@@ -195,7 +247,11 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
);
@@ -207,10 +263,99 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{t("title")}
- {t("comparingMaps", { count: selectedMapIds.length })}
+ {effectiveMapIds.length > 0
+ ? t("comparingMaps", { count: effectiveMapIds.length })
+ : t("subtitle")}
+ {/* Map Selection Mode Toggle */}
+
+
+
+
+
+ {t("mapSelectionMode.label")}
+
+
+ {mapSelectionMode === "individual"
+ ? t("mapSelectionMode.individualDescription")
+ : t("mapSelectionMode.groupsDescription")}
+
+
+
+
+ {t("mapSelectionMode.individual")}
+
+
+ setMapSelectionMode(checked ? "groups" : "individual")
+ }
+ />
+
+ {t("mapSelectionMode.groups")}
+
+
+
+
+ {/* Map Group Selector (only show in groups mode) */}
+ {mapSelectionMode === "groups" && (
+
+
+
+ {t("mapSelectionMode.selectGroups")}
+
+
+
+ {t("mapSelectionMode.manageGroups")}
+
+
+
+
+ {t("mapSelectionMode.groupsHint")}
+
+
+ )}
+
+ {/* Individual Maps Info (only show in individual mode with selected maps) */}
+ {mapSelectionMode === "individual" && selectedMapIds.length > 0 && (
+
+
+ {t("mapSelectionMode.individualMapsSelected", {
+ count: selectedMapIds.length,
+ })}
+
+
+ )}
+
+
+
{/* Filters */}
{/* Comparison Mode Toggle */}
@@ -263,7 +408,7 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{comparisonMode === "player" && (
)}
From 510539789d3a9820ddbce9ed58e4b71ed7ea79fd Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:40:20 -0500
Subject: [PATCH 060/103] Add AddToMapGroupDialog component for managing map
group additions, including selection and creation of new groups
---
.../scrim/add-to-map-group-dialog.tsx | 331 ++++++++++++++++++
1 file changed, 331 insertions(+)
create mode 100644 src/components/scrim/add-to-map-group-dialog.tsx
diff --git a/src/components/scrim/add-to-map-group-dialog.tsx b/src/components/scrim/add-to-map-group-dialog.tsx
new file mode 100644
index 000000000..8bb7df21f
--- /dev/null
+++ b/src/components/scrim/add-to-map-group-dialog.tsx
@@ -0,0 +1,331 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+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 { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Plus } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+
+type AddToMapGroupDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ teamId: number;
+ mapIds: number[];
+ mapName: string;
+};
+
+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 addMapToGroup(
+ groupId: number,
+ mapIds: number[]
+): Promise<{ success: boolean; error?: string }> {
+ const response = await fetch(`/api/compare/map-groups/${groupId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mapIds }),
+ });
+
+ if (!response.ok) {
+ const error = (await response.json()) as { error?: string };
+ return { success: false, error: error.error };
+ }
+
+ return { success: true };
+}
+
+async function createMapGroup(data: {
+ name: string;
+ description?: string;
+ teamId: number;
+ mapIds: number[];
+ category?: string;
+}): Promise<{ success: boolean; error?: string }> {
+ const response = await fetch("/api/compare/map-groups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const error = (await response.json()) as { error?: string };
+ return { success: false, error: error.error };
+ }
+
+ return { success: true };
+}
+
+export function AddToMapGroupDialog({
+ open,
+ onOpenChange,
+ teamId,
+ mapIds,
+ mapName,
+}: AddToMapGroupDialogProps) {
+ const [mode, setMode] = useState<"select" | "create">("select");
+ const [selectedGroupId, setSelectedGroupId] = useState(null);
+ const [newGroupName, setNewGroupName] = useState("");
+ const [newGroupDescription, setNewGroupDescription] = useState("");
+ const [newGroupCategory, setNewGroupCategory] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const queryClient = useQueryClient();
+
+ const { data: mapGroups, isLoading } = useQuery({
+ queryKey: ["mapGroups", teamId],
+ queryFn: () => fetchMapGroups(teamId),
+ enabled: open,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ async function handleAddToExisting() {
+ if (!selectedGroupId) return;
+
+ setIsSubmitting(true);
+ try {
+ const group = mapGroups?.find((g) => g.id === selectedGroupId);
+ if (!group) return;
+
+ // Combine existing map IDs with new ones (deduplicate)
+ const combinedMapIds = Array.from(new Set([...group.mapIds, ...mapIds]));
+
+ const result = await addMapToGroup(selectedGroupId, combinedMapIds);
+
+ if (result.success) {
+ toast.success("Added to map group", {
+ description: `${mapName} added to ${group.name}`,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["mapGroups", teamId],
+ });
+ onOpenChange(false);
+ setSelectedGroupId(null);
+ } else {
+ toast.error("Failed to add to map group", {
+ description: result.error,
+ });
+ }
+ } catch (error) {
+ toast.error("Failed to add to map group", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function handleCreateNew() {
+ if (!newGroupName.trim()) {
+ toast.error("Please enter a name for the map group");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const result = await createMapGroup({
+ name: newGroupName,
+ description: newGroupDescription || undefined,
+ teamId,
+ mapIds,
+ category: newGroupCategory || undefined,
+ });
+
+ if (result.success) {
+ toast.success("Map group created", {
+ description: `Created "${newGroupName}" and added ${mapName}`,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["mapGroups", teamId],
+ });
+ onOpenChange(false);
+ setNewGroupName("");
+ setNewGroupDescription("");
+ setNewGroupCategory("");
+ setMode("select");
+ } else {
+ toast.error("Failed to create map group", {
+ description: result.error,
+ });
+ }
+ } catch (error) {
+ toast.error("Failed to create map group", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ function handleClose() {
+ onOpenChange(false);
+ setMode("select");
+ setSelectedGroupId(null);
+ setNewGroupName("");
+ setNewGroupDescription("");
+ setNewGroupCategory("");
+ }
+
+ return (
+
+ );
+}
From 9de2eae8047478aebb0c15e8246acf8492a0428f Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:40:25 -0500
Subject: [PATCH 061/103] Add map group functionality to the comparison
interface
---
messages/en.json | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/messages/en.json b/messages/en.json
index bd06feb84..e37174dc6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -691,6 +691,7 @@
"contextMenu": {
"title": "Map Actions",
"selectForComparison": "Select for comparison",
+ "addToMapGroup": "Add to map group",
"viewDetails": "View Map Details",
"copyCode": "Copy Replay Code"
}
@@ -699,10 +700,18 @@
"selected": "{count, plural, =1 {1 map selected} other {# maps selected}}",
"fromScrims": "{count, plural, =1 {from 1 scrim} other {from # scrims}}",
"compare": "Compare Selected",
+ "compareNow": "Compare Now",
+ "addToMapGroup": "Add to Map Group",
"clear": "Clear"
},
"viewStats": "View team stats"
},
+ "mapGroupsPage": {
+ "metadata": {
+ "title": "Map Groups | Parsertime",
+ "description": "Create and manage custom map groups for analysis"
+ }
+ },
"comparePage": {
"metadata": {
"title": "Compare Maps | Parsertime",
@@ -717,6 +726,9 @@
"title": "No Maps Selected",
"description": "Select maps from the scrim page to start comparing player performance."
},
+ "noMapGroups": {
+ "description": "Select one or more map groups to start comparing player performance."
+ },
"noPlayer": {
"title": "No Player Selected",
"description": "Select a player to start comparing their performance across maps."
@@ -929,7 +941,9 @@
"damagePer10Short": "Damage",
"healingPer10Short": "Healing",
"mitigatedPer10Short": "Mitigated",
- "averagePerformance": "Average Performance"
+ "averagePerformance": "Average Performance",
+ "errorRange": "Range",
+ "stdDev": "Std Dev"
},
"consistency": {
"title": "Performance Consistency",
@@ -965,6 +979,18 @@
"playerDescription": "Compare individual player performance across maps",
"teamDescription": "Compare your team vs enemy team across maps"
},
+ "mapSelectionMode": {
+ "label": "Map Selection",
+ "individual": "Individual Maps",
+ "groups": "Map Groups",
+ "individualDescription": "Compare using individually selected maps",
+ "groupsDescription": "Compare using predefined map groups (e.g., Brawl Maps, Open Maps)",
+ "selectGroups": "Select Map Groups",
+ "selectGroupsPlaceholder": "Select map groups to compare...",
+ "manageGroups": "Manage Groups",
+ "groupsHint": "You can create and manage map groups from the map groups page",
+ "individualMapsSelected": "{count, plural, =1 {1 map selected from scrim page} other {# maps selected from scrim page}}"
+ },
"teamComparison": {
"myTeam": "My Team",
"enemyTeam": "Enemy Team",
From 50aa67789ae2d8c6878c9be4155a0cb7c9d9aa33 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:43:46 -0500
Subject: [PATCH 062/103] Enhance map group selection in ComparisonContent with
new MapGroupComparisonSelector and improved empty state messaging
---
messages/en.json | 18 ++++++++++-
src/components/compare/comparison-content.tsx | 30 +++++++++++++------
2 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/messages/en.json b/messages/en.json
index e37174dc6..6cf5c8a30 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -727,7 +727,8 @@
"description": "Select maps from the scrim page to start comparing player performance."
},
"noMapGroups": {
- "description": "Select one or more map groups to start comparing player performance."
+ "title": "No Map Groups Selected",
+ "description": "Select one or more map groups below to start comparing player performance."
},
"noPlayer": {
"title": "No Player Selected",
@@ -991,6 +992,21 @@
"groupsHint": "You can create and manage map groups from the map groups page",
"individualMapsSelected": "{count, plural, =1 {1 map selected from scrim page} other {# maps selected from scrim page}}"
},
+ "mapGroupSelector": {
+ "title": "Select Map Groups to Compare",
+ "description": "Choose up to 2 map groups to compare performance. You can compare across different map types, playstyles, or time periods.",
+ "noGroups": {
+ "title": "No Map Groups Available",
+ "description": "Create map groups to organize and compare your team's performance",
+ "createButton": "Create Map Groups"
+ },
+ "hint": {
+ "selectGroups": "Select at least one map group to continue",
+ "selectOneMore": "Select one more group for comparison (optional)",
+ "readyToCompare": "Ready to compare selected groups"
+ },
+ "compareButton": "Compare"
+ },
"teamComparison": {
"myTeam": "My Team",
"enemyTeam": "Enemy Team",
diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx
index c2283a327..2101a5179 100644
--- a/src/components/compare/comparison-content.tsx
+++ b/src/components/compare/comparison-content.tsx
@@ -22,6 +22,7 @@ import { DeltaView } from "./delta-view";
import { DetailedStatsView } from "./detailed-stats-view";
import { EmptyState } from "./empty-state";
import { ImpactMetricsView } from "./impact-metrics-view";
+import { MapGroupComparisonSelector } from "./map-group-comparison-selector";
import { MapGroupSelector } from "./map-group-selector";
import { SideBySideView } from "./side-by-side-view";
import { TeamComparisonView } from "./team-comparison-view";
@@ -244,15 +245,26 @@ export function ComparisonContent({ teamId }: ComparisonContentProps) {
{t("subtitle")}
-
+ {mapSelectionMode === "groups" ? (
+
+ {
+ setSelectedMapGroups(groupIds);
+ }}
+ />
+
+ ) : (
+
+ )}
);
}
From 1c72f6d6fc12f94d74ba437278113a8652f9779c Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:43:50 -0500
Subject: [PATCH 063/103] Enhance EmptyState component to support optional
children prop for additional content rendering
---
src/components/compare/empty-state.tsx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/components/compare/empty-state.tsx b/src/components/compare/empty-state.tsx
index c6f9b7537..4f04d72b1 100644
--- a/src/components/compare/empty-state.tsx
+++ b/src/components/compare/empty-state.tsx
@@ -2,14 +2,21 @@
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, MapPin, TrendingDown, UserX } from "lucide-react";
+import type { ReactNode } from "react";
type EmptyStateProps = {
icon: "MapPin" | "UserX" | "TrendingDown" | "Loader";
title: string;
description: string;
+ children?: ReactNode;
};
-export function EmptyState({ icon, title, description }: EmptyStateProps) {
+export function EmptyState({
+ icon,
+ title,
+ description,
+ children,
+}: EmptyStateProps) {
const Icon =
icon === "MapPin"
? MapPin
@@ -27,6 +34,7 @@ export function EmptyState({ icon, title, description }: EmptyStateProps) {
{title}
{description}
+ {children &&
{children}
}
);
From 7487b328746cbb614ba62c0976586781147f6e2b Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 01:43:54 -0500
Subject: [PATCH 064/103] Add MapGroupComparisonSelector component for enhanced
map group selection in comparison interface
---
.../compare/map-group-comparison-selector.tsx | 164 ++++++++++++++++++
1 file changed, 164 insertions(+)
create mode 100644 src/components/compare/map-group-comparison-selector.tsx
diff --git a/src/components/compare/map-group-comparison-selector.tsx b/src/components/compare/map-group-comparison-selector.tsx
new file mode 100644
index 000000000..192c60ba7
--- /dev/null
+++ b/src/components/compare/map-group-comparison-selector.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import type { FormattedMapGroup } from "@/types/map-group";
+import { useQuery } from "@tanstack/react-query";
+import { Check, FolderCog, FolderOpen, Loader2 } from "lucide-react";
+import type { Route } from "next";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { useState } from "react";
+
+type MapGroupComparisonSelectorProps = {
+ teamId: number;
+ onSelect: (groupIds: number[]) => void;
+};
+
+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;
+}
+
+export function MapGroupComparisonSelector({
+ teamId,
+ onSelect,
+}: MapGroupComparisonSelectorProps) {
+ const t = useTranslations("comparePage.mapGroupSelector");
+ const [selectedGroups, setSelectedGroups] = useState([]);
+
+ const { data: mapGroups, isLoading } = useQuery({
+ queryKey: ["mapGroups", teamId],
+ queryFn: () => fetchMapGroups(teamId),
+ staleTime: 5 * 60 * 1000,
+ });
+
+ function handleToggleGroup(groupId: number) {
+ setSelectedGroups((prev) => {
+ if (prev.includes(groupId)) {
+ return prev.filter((id) => id !== groupId);
+ }
+ // Limit to 2 groups for comparison
+ if (prev.length >= 2) {
+ return [prev[1], groupId];
+ }
+ return [...prev, groupId];
+ });
+ }
+
+ function handleCompare() {
+ if (selectedGroups.length > 0) {
+ onSelect(selectedGroups);
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!mapGroups || mapGroups.length === 0) {
+ return (
+
+
+
+
+
+
{t("noGroups.title")}
+
+ {t("noGroups.description")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+ {mapGroups.map((group) => {
+ const isSelected = selectedGroups.includes(group.id);
+ return (
+
handleToggleGroup(group.id)}
+ >
+
+
+
+
+
+
+ {group.name}
+
+ {group.mapCount}
+
+
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ {selectedGroups.length === 0
+ ? t("hint.selectGroups")
+ : selectedGroups.length === 1
+ ? t("hint.selectOneMore")
+ : t("hint.readyToCompare")}
+
+
+
+
+ );
+}
From 56f6a3c33f357c0cff6d71eb0d56f1e998ff4b00 Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 18:44:55 -0500
Subject: [PATCH 065/103] Enhance VodOverview component to support additional
YouTube and Twitch URL formats
---
src/components/vods/vod-overview.tsx | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/components/vods/vod-overview.tsx b/src/components/vods/vod-overview.tsx
index 64b5eec18..278289d04 100644
--- a/src/components/vods/vod-overview.tsx
+++ b/src/components/vods/vod-overview.tsx
@@ -29,8 +29,11 @@ export function VodOverview({ vod, mapId }: { vod: string; mapId: number }) {
}
const vodSource =
- vodState.startsWith("https://www.youtube.com/watch?v=") ||
- vodState.startsWith("https://youtu.be/")
+ vodState.startsWith("https://www.youtube.com/") ||
+ vodState.startsWith("https://youtu.be/") ||
+ vodState.startsWith("https://youtube.com/") ||
+ vodState.startsWith("https://www.youtube.com/embed/") ||
+ vodState.startsWith("https://youtube.com/embed/")
? "youtube"
: vodState.startsWith("https://www.twitch.tv/videos/")
? "twitch"
@@ -48,7 +51,11 @@ export function VodOverview({ vod, mapId }: { vod: string; mapId: number }) {
videoid={
vodState.startsWith("https://youtu.be/")
? vodState.split("youtu.be/")[1].split("?")[0]
- : vodState.split("v=")[1]?.split("&")[0] || ""
+ : vodState.includes("/embed/")
+ ? vodState.split("/embed/")[1].split("?")[0]
+ : vodState.includes("/live/")
+ ? vodState.split("/live/")[1].split("?")[0]
+ : vodState.split("v=")[1]?.split("&")[0] || ""
}
params={`controls=1&start=${vodState.split("t=")[1] ? vodState.split("t=")[1].split("s")[0] : 0}`}
style="width:full; height:full; max-width:100%; max-height:100%; border:0;"
@@ -58,6 +65,7 @@ export function VodOverview({ vod, mapId }: { vod: string; mapId: number }) {
{vodSource === "twitch" && (
From bb83f1bfe5a2a742a9d4dee34957b77aaf7da31e Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 28 Jan 2026 15:48:09 -0500
Subject: [PATCH 066/103] Update mapNameToMapTypeMapping to change "Watchpoint:
Gibraltar" from Hybrid to Escort
---
src/types/map.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/types/map.ts b/src/types/map.ts
index c98dc6e16..e84bbcc8f 100644
--- a/src/types/map.ts
+++ b/src/types/map.ts
@@ -39,5 +39,5 @@ export const mapNameToMapTypeMapping = {
"Shambali Monastery": $Enums.MapType.Escort,
Suravasa: $Enums.MapType.Flashpoint,
"Throne of Anubis": $Enums.MapType.Clash,
- "Watchpoint: Gibraltar": $Enums.MapType.Hybrid,
+ "Watchpoint: Gibraltar": $Enums.MapType.Escort,
} as const;
From 7f506d0dd1e4bd87e82e513d43ee4efbb9a1893b Mon Sep 17 00:00:00 2001
From: Lucas Doell
Date: Wed, 11 Feb 2026 00:52:46 -0500
Subject: [PATCH 067/103] Add new heroes for Overwatch 2026
---
public/heroes/anran.png | Bin 0 -> 135431 bytes
public/heroes/domina.png | Bin 0 -> 160090 bytes
public/heroes/emre.png | Bin 0 -> 146338 bytes
public/heroes/jetpackcat.png | Bin 0 -> 171624 bytes
public/heroes/mizuki.png | Bin 0 -> 148607 bytes
src/types/heroes.ts | 53 ++++++++++++++++++++++++++++++++++-
6 files changed, 52 insertions(+), 1 deletion(-)
create mode 100644 public/heroes/anran.png
create mode 100644 public/heroes/domina.png
create mode 100644 public/heroes/emre.png
create mode 100644 public/heroes/jetpackcat.png
create mode 100644 public/heroes/mizuki.png
diff --git a/public/heroes/anran.png b/public/heroes/anran.png
new file mode 100644
index 0000000000000000000000000000000000000000..9b31bb71fcda9a38190fa0145530f229da39fe48
GIT binary patch
literal 135431
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 | |