diff --git a/.gitignore b/.gitignore index a30fe21e5..a42004cce 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,4 @@ next-env.d.ts .idea # CI -reports \ No newline at end of file +/reports \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index d80d1ad8d..4dc4977f0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -39,6 +39,7 @@ }, "heroes": { "ana": "Ana", + "anran": "Anran", "ashe": "Ashe", "baptiste": "Baptiste", "bastion": "Bastion", @@ -46,12 +47,15 @@ "cassidy": "Cassidy", "doomfist": "Doomfist", "dva": "D.Va", + "domina": "Domina", "echo": "Echo", + "emre": "Emre", "freja": "Freja", "genji": "Genji", "hanzo": "Hanzo", "hazard": "Hazard", "illari": "Illari", + "jetpackcat": "Jetpack Cat", "junkerqueen": "Junker Queen", "junkrat": "Junkrat", "juno": "Juno", @@ -61,6 +65,7 @@ "mauga": "Mauga", "mei": "Mei", "mercy": "Mercy", + "mizuki": "Mizuki", "moira": "Moira", "orisa": "Orisa", "pharah": "Pharah", @@ -684,8 +689,356 @@ }, "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", + "addToMapGroup": "Add to map group", + "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", + "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", + "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." + }, + "noMapGroups": { + "title": "No Map Groups Selected", + "description": "Select one or more map groups below 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." + }, + "noTeamData": { + "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", + "detailedStats": "Detailed Stats", + "impactMetrics": "Impact Metrics" + }, + "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", + "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", + "avgPer10": "Avg per 10", + "eliminations": "Eliminations", + "deaths": "Deaths", + "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)", + "elimsPer10Short": "Elims", + "deathsPer10Short": "Deaths", + "damagePer10Short": "Damage", + "healingPer10Short": "Healing", + "mitigatedPer10Short": "Mitigated", + "averagePerformance": "Average Performance", + "errorRange": "Range", + "stdDev": "Std Dev" + }, + "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" + } + }, + "comparisonMode": { + "label": "Comparison Mode", + "player": "Player", + "team": "Team", + "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}}" + }, + "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", + "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": { "mapMetadata": { "title": "{mapName} Overview | Parsertime", @@ -746,7 +1099,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", @@ -914,11 +1267,13 @@ "deaths": "Deaths", "kd": "K/D", "kad": "KA/D", + "firstDeathRate": "1st Death %", + "teamFirstDeathRate": "Team 1st Death %", "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" }, @@ -938,6 +1293,8 @@ "deaths": "The number of deaths the player had.", "kd": "The player's kill/death ratio.", "kad": "The player's kill/assist/death ratio.", + "firstDeathRate": "Percentage of fights where the player was the first to die overall.", + "teamFirstDeathRate": "Percentage of fights where the player was the first on their team to die.", "heroDmgDealt": "The amount of hero damage the player dealt.", "dmgReceived": "The amount of damage the player received.", "healingReceived": "The amount of healing the player received.", @@ -1057,8 +1414,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": { @@ -1093,8 +1450,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", @@ -1376,7 +1733,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", @@ -1497,7 +1854,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", diff --git a/messages/ko.json b/messages/ko.json index 553512f0e..d93ce74fb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -39,6 +39,7 @@ }, "heroes": { "ana": "아나", + "anran": "안란", "ashe": "애쉬", "baptiste": "바티스트", "bastion": "바스티온", @@ -46,12 +47,15 @@ "cassidy": "캐서디", "doomfist": "둠피스트", "dva": "디바", + "domina": "도미나", "echo": "에코", + "emre": "엠레", "freja": "프레야", "genji": "겐지", "hanzo": "한조", "hazard": "해저드", "illari": "일라리", + "jetpackcat": "제트팩 캣", "junkerqueen": "정커 퀸", "junkrat": "정크랫", "juno": "주노", @@ -61,6 +65,7 @@ "mauga": "마우가", "mei": "메이", "mercy": "메르시", + "mizuki": "미즈키", "moira": "모이라", "orisa": "오리사", "pharah": "파라", @@ -794,6 +799,8 @@ "deaths": "죽음", "kd": "킬/데스", "kad": "킬/지원/데스", + "firstDeathRate": "첫 사망 %", + "teamFirstDeathRate": "팀 첫 사망 %", "heroDmgDealt": "가한 영웅 피해", "dmgReceived": "받은 피해", "healingReceived": "받은 치유", @@ -818,6 +825,8 @@ "deaths": "플레이어의 죽음 수입니다.", "kd": "플레이어의 킬/데스 비율입니다.", "kad": "플레이어의 킬/지원/데스 비율입니다.", + "firstDeathRate": "전체 전투에서 플레이어가 처음으로 사망한 비율입니다.", + "teamFirstDeathRate": "전체 전투에서 플레이어가 팀에서 처음으로 사망한 비율입니다.", "heroDmgDealt": "플레이어가 가한 영웅 피해량입니다.", "dmgReceived": "플레이어가 받은 피해량입니다.", "healingReceived": "플레이어가 받은 치유량입니다.", diff --git a/messages/zh.json b/messages/zh.json index 78ac0f75f..f78846ebd 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -39,6 +39,7 @@ }, "heroes": { "ana": "安娜", + "anran": "安燃", "ashe": "艾什", "baptiste": "巴蒂斯特", "bastion": "堡垒", @@ -46,12 +47,15 @@ "cassidy": "卡西迪", "doomfist": "末日铁拳", "dva": "D.Va", + "domina": "多米娜", "echo": "回声", + "emre": "伊默", "freja": "弗蕾亞", "genji": "源氏", "hanzo": "半藏", "hazard": "骇灾", "illari": "伊拉锐", + "jetpackcat": "火箭貓", "junkerqueen": "渣客女王", "junkrat": "狂鼠", "juno": "朱诺", @@ -61,6 +65,7 @@ "mauga": "毛加", "mei": "美", "mercy": "天使", + "mizuki": "瑞稀", "moira": "莫伊拉", "orisa": "奥丽莎", "pharah": "法老之鹰", @@ -794,6 +799,8 @@ "deaths": "死亡次数", "kd": "击杀/死亡比", "kad": "击杀+助攻/死亡比", + "firstDeathRate": "首死率", + "teamFirstDeathRate": "团队首死率", "heroDmgDealt": "英雄伤害", "dmgReceived": "受到的伤害", "healingReceived": "接受的治疗", @@ -818,6 +825,8 @@ "deaths": "玩家的死亡次数。", "kd": "玩家的击杀/死亡比。", "kad": "玩家的击杀+助攻/死亡比。", + "firstDeathRate": "玩家在所有战斗中率先阵亡的百分比。", + "teamFirstDeathRate": "玩家在所有战斗中作为队伍内首个阵亡的百分比。", "heroDmgDealt": "玩家造成的英雄伤害量。", "dmgReceived": "玩家承受的伤害量。", "healingReceived": "玩家接受的治疗量。", diff --git a/package.json b/package.json index 6ace0c7a8..5bd516312 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/language-service": "^0.40.0", + "@flags-sdk/vercel": "^1.0.1", "@floating-ui/react": "^0.27.16", "@headlessui/react": "^2.2.8", "@heroicons/react": "^2.2.0", @@ -89,6 +90,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/blob": "^2.0.0", "@vercel/edge-config": "^1.4.0", + "@vercel/flags-core": "^1.0.1", "@vercel/functions": "^3.1.0", "@vercel/kv": "^3.0.0", "@vercel/speed-insights": "^1.2.0", @@ -103,6 +105,7 @@ "date-fns": "^4.1.0", "effect": "^3.17.13", "embla-carousel-react": "^8.6.0", + "flags": "^4.0.3", "framer-motion": "^12.23.13", "fuse.js": "^7.1.0", "geist": "^1.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad851bcb8..c9521ff71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@effect/language-service': specifier: ^0.40.0 version: 0.40.0 + '@flags-sdk/vercel': + specifier: ^1.0.1 + version: 1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.890.0)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -223,6 +226,9 @@ importers: '@vercel/edge-config': specifier: ^1.4.0 version: 1.4.0(@opentelemetry/api@1.9.0) + '@vercel/flags-core': + specifier: ^1.0.1 + version: 1.0.1(@aws-sdk/credential-provider-web-identity@3.890.0)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) '@vercel/functions': specifier: ^3.1.0 version: 3.1.0(@aws-sdk/credential-provider-web-identity@3.890.0) @@ -265,6 +271,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) + flags: + specifier: ^4.0.3 + version: 4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) framer-motion: specifier: ^12.23.13 version: 12.23.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -729,6 +738,10 @@ packages: peerDependencies: react: '>=16.8.0' + '@edge-runtime/cookies@5.0.2': + resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} + engines: {node: '>=16'} + '@effect/language-service@0.40.0': resolution: {integrity: sha512-VHikOhYXm+ECy+mLSszHgCfP9YyKQyB9GLFGBKY2nbnru9xwgCnar8pBLA0AkSUjAgn3hc/mdcFdb/XL9uywLQ==} hasBin: true @@ -982,6 +995,12 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@flags-sdk/vercel@1.0.1': + resolution: {integrity: sha512-RKdoetYr0Qp6ecbiary4QRmwkl0FYvRlA0hE0u/chz1Ao4NOV05x+moisUvAt4oNMSpgNjCcwtrg9ct1iF1mAw==} + peerDependencies: + '@vercel/flags-core': 1.0.1 + flags: '*' + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -3443,6 +3462,18 @@ packages: '@opentelemetry/api': optional: true + '@vercel/flags-core@1.0.1': + resolution: {integrity: sha512-jlj7J/bctERGbFYVCGK2DNXQ2dz2ol7Km2kduhJ0QMrqCsIlloq4HY6jgyJPToe9VpPleJyqT6TaYJACUqdCYg==} + peerDependencies: + '@openfeature/server-sdk': 1.18.0 + flags: '*' + next: '*' + peerDependenciesMeta: + '@openfeature/server-sdk': + optional: true + next: + optional: true + '@vercel/functions@2.2.13': resolution: {integrity: sha512-14ArBSIIcOBx9nrEgaJb4Bw+en1gl6eSoJWh8qjifLl5G3E4dRXCFOT8HP+w66vb9Wqyd1lAQBrmRhRwOj9X9A==} engines: {node: '>= 18'} @@ -3461,6 +3492,15 @@ packages: '@aws-sdk/credential-provider-web-identity': optional: true + '@vercel/functions@3.4.2': + resolution: {integrity: sha512-WDsNNuGOOUhRYxfcSgk8nlXaYW/6u1Lw2eaKm0y4+gDPGK/hwmYIeP2hESfccuCiPXd5ZGO8Jz/V5Ud3ZByoUQ==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + '@vercel/kv@3.0.0': resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} engines: {node: '>=14.6'} @@ -3500,6 +3540,10 @@ packages: resolution: {integrity: sha512-XOoUcf/1VfGArUAfq0ELxk6TD7l4jGcrOsWjQibj4wYM74uNihzZ9gA46ywWegoqKWWdph4y5CKxGI9823deoA==} engines: {node: '>= 20'} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vercel/speed-insights@1.2.0': resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} peerDependencies: @@ -4387,6 +4431,26 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flags@4.0.3: + resolution: {integrity: sha512-rLkO+Hn6dSEsDZm6lHuXr3GjfHf8N67lhXCFUeSRBjDdb/43ez5Je8DC/K0HzMtl3LcWc7zgF79V/3WzJXVm/w==} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + '@sveltejs/kit': '*' + next: '*' + react: '*' + react-dom: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -4765,6 +4829,12 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + jose@5.2.1: + resolution: {integrity: sha512-qiaQhtQRw6YrOaOj0v59h3R6hUY9NvxBmmnMfKemkqYmBB0tEc97NbLP7ix44VP5p9/0YHG8Vyhzuo5YBNwviA==} + jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} @@ -4774,6 +4844,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-xxhash@4.0.0: + resolution: {integrity: sha512-3Q2eIqG6s1KEBBmkj9tGM9lef8LJbuRyTVBdI3GpTnrvtytunjLPO0wqABp5qhtMzfA32jYn1FlnIV7GH1RAHQ==} + engines: {node: '>=18.0.0'} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -6809,6 +6883,8 @@ snapshots: react: 19.2.3 tslib: 2.8.1 + '@edge-runtime/cookies@5.0.2': {} + '@effect/language-service@0.40.0': {} '@emnapi/core@1.5.0': @@ -7031,6 +7107,11 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@flags-sdk/vercel@1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.890.0)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': + dependencies: + '@vercel/flags-core': 1.0.1(@aws-sdk/credential-provider-web-identity@3.890.0)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) + flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -9566,6 +9647,17 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 + '@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.890.0)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))': + dependencies: + '@vercel/functions': 3.4.2(@aws-sdk/credential-provider-web-identity@3.890.0) + flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + jose: 5.2.1 + js-xxhash: 4.0.0 + optionalDependencies: + next: 16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-web-identity' + '@vercel/functions@2.2.13(@aws-sdk/credential-provider-web-identity@3.890.0)': dependencies: '@vercel/oidc': 2.0.2 @@ -9578,6 +9670,12 @@ snapshots: optionalDependencies: '@aws-sdk/credential-provider-web-identity': 3.890.0 + '@vercel/functions@3.4.2(@aws-sdk/credential-provider-web-identity@3.890.0)': + dependencies: + '@vercel/oidc': 3.2.0 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.890.0 + '@vercel/kv@3.0.0': dependencies: '@upstash/redis': 1.35.3 @@ -9613,6 +9711,8 @@ snapshots: '@types/ms': 2.1.0 ms: 2.1.3 + '@vercel/oidc@3.2.0': {} + '@vercel/speed-insights@1.2.0(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': optionalDependencies: next: 16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) @@ -10392,7 +10492,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.39.2(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.5.1)))(eslint@9.39.2(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.39.2(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -10414,7 +10514,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.39.2(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.5.1)))(eslint@9.39.2(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.39.2(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10727,6 +10827,16 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@edge-runtime/cookies': 5.0.2 + jose: 5.10.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + next: 16.1.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -11102,12 +11212,18 @@ snapshots: jose@4.15.9: {} + jose@5.10.0: {} + + jose@5.2.1: {} + jose@6.1.0: {} js-tokens@4.0.0: {} js-tokens@9.0.1: {} + js-xxhash@4.0.0: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 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/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/migrations/20260223235822_add_map_ban_model/migration.sql b/prisma/migrations/20260223235822_add_map_ban_model/migration.sql new file mode 100644 index 000000000..18b393b4a --- /dev/null +++ b/prisma/migrations/20260223235822_add_map_ban_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "public"."MapBan" ( + "id" SERIAL NOT NULL, + "scrimId" INTEGER NOT NULL, + "map" TEXT NOT NULL, + "team" TEXT NOT NULL, + "banPosition" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "MapDataId" INTEGER NOT NULL, + + CONSTRAINT "MapBan_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MapBan_scrimId_idx" ON "public"."MapBan"("scrimId"); + +-- CreateIndex +CREATE INDEX "MapBan_MapDataId_idx" ON "public"."MapBan"("MapDataId"); + +-- AddForeignKey +ALTER TABLE "public"."MapBan" ADD CONSTRAINT "MapBan_MapDataId_fkey" FOREIGN KEY ("MapDataId") REFERENCES "public"."MapData"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1473f7d4..6ceeaff16 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,28 +42,30 @@ 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[] + mapGroups MapGroup[] @@index([id, email]) } @@ -183,16 +185,18 @@ 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[] + mapGroups MapGroup[] } model TeamManager { @@ -220,6 +224,42 @@ 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 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 @@ -264,6 +304,21 @@ model HeroBan { @@index([MapDataId]) } +model MapBan { + id Int @id @default(autoincrement()) + scrimId Int + map String + team String + banPosition Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + MapData MapData? @relation(fields: [MapDataId], references: [id]) + MapDataId Int + + @@index([scrimId]) + @@index([MapDataId]) +} + model Note { id Int @id @default(autoincrement()) content String @default("") @@ -394,6 +449,7 @@ model MapData { ultimate_end UltimateEnd[] ultimate_start UltimateStart[] HeroBan HeroBan[] + mapBans MapBan[] Note Note[] calculatedStats CalculatedStat[] diff --git a/public/heroes/anran.png b/public/heroes/anran.png new file mode 100644 index 000000000..9b31bb71f Binary files /dev/null and b/public/heroes/anran.png differ diff --git a/public/heroes/domina.png b/public/heroes/domina.png new file mode 100644 index 000000000..2a043478f Binary files /dev/null and b/public/heroes/domina.png differ diff --git a/public/heroes/emre.png b/public/heroes/emre.png new file mode 100644 index 000000000..0c55ec2b7 Binary files /dev/null and b/public/heroes/emre.png differ diff --git a/public/heroes/jetpackcat.png b/public/heroes/jetpackcat.png new file mode 100644 index 000000000..14f856471 Binary files /dev/null and b/public/heroes/jetpackcat.png differ diff --git a/public/heroes/mizuki.png b/public/heroes/mizuki.png new file mode 100644 index 000000000..a69176fcb Binary files /dev/null and b/public/heroes/mizuki.png differ diff --git a/src/app/.well-known/vercel/flags/route.ts b/src/app/.well-known/vercel/flags/route.ts new file mode 100644 index 000000000..1eca35103 --- /dev/null +++ b/src/app/.well-known/vercel/flags/route.ts @@ -0,0 +1,4 @@ +import * as flags from "@/lib/flags"; +import { createFlagsDiscoveryEndpoint, getProviderData } from "flags/next"; + +export const GET = createFlagsDiscoveryEndpoint(() => getProviderData(flags)); 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 ( + + + + ); +} 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 ( + +
+
+

Map Groups

+
+ +
+
+ ); +} diff --git a/src/app/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx index 1624f903c..a127508b2 100644 --- a/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/page.tsx @@ -1,14 +1,10 @@ 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 { ScrimOverviewCard } from "@/components/scrim/scrim-overview-card"; 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, @@ -18,14 +14,13 @@ import { import { getScrim } from "@/data/scrim-dto"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { mapComparison, overviewCard } from "@/lib/flags"; 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 +74,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 +91,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 +111,103 @@ export default async function ScrimDashboardPage( }, })) ?? { guestMode: false }; - const mapNames = await getMapNames(); + const [mapComparisonEnabled, overviewCardEnabled] = await Promise.all([ + mapComparison(), + overviewCard(), + ]); return ( -
-

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

-
-

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

-
-

- -

-

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

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

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

+
+

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

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

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

- ))} - {hasPerms && } + )} + +
- ) : ( - <> - - - {t("noMaps.title")} - - {t("noMaps.description")} - - {t("noMaps.link")} - - . - - -
{hasPerms && }
- +

+ +

+
+ + {/* Overview Card */} + {overviewCardEnabled && maps.length > 0 && ( + )} + + {/* Maps Section */} +
+
+

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

+
+ + {maps.length > 0 ? ( +
+ {maps.map((map) => ( + + ))} + {hasPerms && } +
+ ) : ( + <> + + + {t("noMaps.title")} + + {t("noMaps.description")} + + {t("noMaps.link")} + + . + + +
{hasPerms && }
+ + )} +
+ + {/* Compare Selected Button */} + {mapComparisonEnabled && } ); } 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/route.ts b/src/app/api/compare/groups/route.ts new file mode 100644 index 000000000..654c310c0 --- /dev/null +++ b/src/app/api/compare/groups/route.ts @@ -0,0 +1,294 @@ +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 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 = { + 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/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); + } +} 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..0d9ea2c3e --- /dev/null +++ b/src/app/api/compare/players/route.ts @@ -0,0 +1,112 @@ +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 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({ + 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); + } +} 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 } + ); + } +} diff --git a/src/app/api/reports/send-usage-report/route.ts b/src/app/api/reports/send-usage-report/route.ts new file mode 100644 index 000000000..efa037857 --- /dev/null +++ b/src/app/api/reports/send-usage-report/route.ts @@ -0,0 +1,208 @@ +import { UsageReportEmail } from "@/components/email/usage-report"; +import { email } from "@/lib/email"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { render } from "@react-email/render"; + +function formatDate(date: Date): string { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get("Authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("Unauthorized", { status: 401 }); + } + + const now = new Date(); + const weekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const prevWeekStart = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + const [ + totalUsers, + newUsersThisWeek, + newUsersLastWeek, + totalScrims, + newScrimsThisWeek, + newScrimsLastWeek, + totalTeams, + newTeamsThisWeek, + newTeamsLastWeek, + paidUsers, + billingPlanGroups, + oauthAccounts, + topUserScrimGroups, + topTeamScrimGroups, + ] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { createdAt: { gte: weekStart } } }), + prisma.user.count({ + where: { createdAt: { gte: prevWeekStart, lt: weekStart } }, + }), + prisma.scrim.count(), + prisma.scrim.count({ where: { createdAt: { gte: weekStart } } }), + prisma.scrim.count({ + where: { createdAt: { gte: prevWeekStart, lt: weekStart } }, + }), + prisma.team.count(), + prisma.team.count({ where: { createdAt: { gte: weekStart } } }), + prisma.team.count({ + where: { createdAt: { gte: prevWeekStart, lt: weekStart } }, + }), + prisma.user.count({ where: { billingPlan: { not: "FREE" } } }), + prisma.user.groupBy({ by: ["billingPlan"], _count: { id: true } }), + prisma.account.groupBy({ by: ["provider"], _count: { userId: true } }), + prisma.scrim.groupBy({ + by: ["creatorId"], + where: { createdAt: { gte: weekStart } }, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }), + prisma.scrim.groupBy({ + by: ["teamId"], + where: { createdAt: { gte: weekStart }, teamId: { not: null } }, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }), + ]); + + const topUserIds = topUserScrimGroups.map((g) => g.creatorId); + const topTeamIds = topTeamScrimGroups + .map((g) => g.teamId) + .filter((id): id is number => id !== null); + + const [topUserDetails, topTeamDetails] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: topUserIds } }, + select: { id: true, name: true, email: true }, + }), + prisma.team.findMany({ + where: { id: { in: topTeamIds } }, + select: { id: true, name: true }, + }), + ]); + + const topUsers = topUserScrimGroups.map((group) => { + const user = topUserDetails.find((u) => u.id === group.creatorId); + return { + name: user?.name ?? user?.email ?? "Unknown User", + scrimCount: group._count.id, + }; + }); + + const topTeams = topTeamScrimGroups + .filter((g) => g.teamId !== null) + .map((group) => { + const team = topTeamDetails.find((t) => t.id === group.teamId); + return { + name: team?.name ?? `Team ${group.teamId}`, + scrimCount: group._count.id, + }; + }); + + const conversionRate = totalUsers > 0 ? (paidUsers / totalUsers) * 100 : 0; + + const billingPlanTotal = billingPlanGroups.reduce( + (sum, p) => sum + p._count.id, + 0 + ); + const billingPlans = billingPlanGroups.map((p) => ({ + plan: p.billingPlan, + count: p._count.id, + percentage: + billingPlanTotal > 0 + ? Math.round((p._count.id / billingPlanTotal) * 100) + : 0, + })); + + const providerCounts = new Map(); + let totalOAuthUsers = 0; + for (const account of oauthAccounts) { + providerCounts.set(account.provider, account._count.userId); + totalOAuthUsers += account._count.userId; + } + const emailOnlyUsers = totalUsers - totalOAuthUsers; + const signupMethodsTotal = totalUsers; + + const signupMethods = [ + ...(emailOnlyUsers > 0 + ? [ + { + method: "Email", + count: emailOnlyUsers, + percentage: + signupMethodsTotal > 0 + ? Math.round((emailOnlyUsers / signupMethodsTotal) * 100) + : 0, + }, + ] + : []), + ...["discord", "google", "github"] + .filter((provider) => (providerCounts.get(provider) ?? 0) > 0) + .map((provider) => { + const count = providerCounts.get(provider) ?? 0; + return { + method: provider.charAt(0).toUpperCase() + provider.slice(1), + count, + percentage: + signupMethodsTotal > 0 + ? Math.round((count / signupMethodsTotal) * 100) + : 0, + }; + }), + ]; + + const emailHtml = await render( + UsageReportEmail({ + weekStart: formatDate(weekStart), + weekEnd: formatDate(now), + newUsersThisWeek, + newUsersLastWeek, + newScrimsThisWeek, + newScrimsLastWeek, + newTeamsThisWeek, + newTeamsLastWeek, + totalUsers, + totalScrims, + totalTeams, + paidUsers, + conversionRate, + signupMethods, + billingPlans, + topUsers, + topTeams, + }) + ); + + try { + await email.sendEmail({ + to: "lucas@lux.dev", + subject: `Parsertime Weekly Report — ${formatDate(weekStart)} to ${formatDate(now)}`, + html: emailHtml, + }); + Logger.info(`Usage report sent to lucas@lux.dev`); + } catch (err) { + Logger.error(`Failed to send usage report to lucas@lux.dev`, err); + return new Response("Failed to send usage report", { status: 500 }); + } + + return Response.json({ + success: true, + stats: { + newUsersThisWeek, + newScrimsThisWeek, + newTeamsThisWeek, + totalUsers, + totalScrims, + totalTeams, + paidUsers, + conversionRate, + }, + }); +} diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx new file mode 100644 index 000000000..cde5337d0 --- /dev/null +++ b/src/components/compare/charts-view.tsx @@ -0,0 +1,642 @@ +"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 { ComparisonStats } from "@/data/comparison-dto"; +import { useTranslations } from "next-intl"; +import { + Bar, + BarChart, + CartesianGrid, + ErrorBar, + Line, + LineChart, + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, + XAxis, + YAxis, +} from "recharts"; + +type ChartsViewProps = { + stats: ComparisonStats; + 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) 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 = { + elimsPer10: { + label: t("elimsPer10"), + color: "var(--chart-1)", + }, + deathsPer10: { + label: t("deathsPer10"), + color: "var(--chart-2)", + }, + damagePer10: { + label: t("damagePer10K"), + color: "var(--chart-3)", + }, + }; + + // 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("elimsPer10"), + map1: stats.perMapBreakdown[0].stats.eliminationsPer10 ?? 0, + map2: stats.perMapBreakdown[1].stats.eliminationsPer10 ?? 0, + }, + { + stat: t("deathsPer10"), + map1: stats.perMapBreakdown[0].stats.deathsPer10 ?? 0, + map2: stats.perMapBreakdown[1].stats.deathsPer10 ?? 0, + }, + { + stat: t("damagePer10K"), + map1: (stats.perMapBreakdown[0].stats.allDamagePer10 ?? 0) / 1000, + map2: (stats.perMapBreakdown[1].stats.allDamagePer10 ?? 0) / 1000, + }, + { + stat: t("healingPer10K"), + map1: + (stats.perMapBreakdown[0].stats.healingDealtPer10 ?? 0) / 1000, + map2: + (stats.perMapBreakdown[1].stats.healingDealtPer10 ?? 0) / 1000, + }, + { + stat: t("blockedPer10K"), + map1: + (stats.perMapBreakdown[0].stats.damageBlockedPer10 ?? 0) / 1000, + map2: + (stats.perMapBreakdown[1].stats.damageBlockedPer10 ?? 0) / 1000, + }, + ] + : []; + + const barChartConfig: ChartConfig = + viewMode === "two-map" && stats.perMapBreakdown.length === 2 + ? { + map1: { + label: `${stats.perMapBreakdown[0].scrimName} - ${stats.perMapBreakdown[0].mapName}`, + color: "var(--chart-1)", + }, + map2: { + label: `${stats.perMapBreakdown[1].scrimName} - ${stats.perMapBreakdown[1].mapName}`, + color: "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.allDamagePer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1]?.stats.allDamagePer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + { + metric: t("healingPer10Short"), + map1: Number( + ( + (stats.perMapBreakdown[0]?.stats.healingDealtPer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1]?.stats.healingDealtPer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + { + metric: t("mitigatedPer10Short"), + map1: Number( + ( + (stats.perMapBreakdown[0]?.stats.damageBlockedPer10 ?? 0) / 1000 + ).toFixed(2) + ), + map2: Number( + ( + (stats.perMapBreakdown[1]?.stats.damageBlockedPer10 ?? 0) / 1000 + ).toFixed(2) + ), + }, + ] + : [ + { + metric: t("elimsPer10Short"), + value: Number( + (stats.aggregated?.eliminationsPer10 ?? 0).toFixed(2) + ), + }, + { + metric: t("deathsPer10Short"), + value: Number( + (20 - (stats.aggregated?.deathsPer10 ?? 0)).toFixed(2) + ), // Invert + }, + { + metric: t("damagePer10Short"), + value: Number( + ((stats.aggregated?.allDamagePer10 ?? 0) / 1000).toFixed(2) + ), + }, + { + metric: t("healingPer10Short"), + value: Number( + ((stats.aggregated?.healingDealtPer10 ?? 0) / 1000).toFixed(2) + ), + }, + { + metric: t("mitigatedPer10Short"), + value: Number( + ((stats.aggregated?.damageBlockedPer10 ?? 0) / 1000).toFixed(2) + ), + }, + ]; + + const radarChartConfig: ChartConfig = + viewMode === "two-map" && stats.perMapBreakdown?.length === 2 + ? { + map1: { + label: `${stats.perMapBreakdown[0].scrimName} - ${stats.perMapBreakdown[0].mapName}`, + color: "var(--chart-1)", + }, + map2: { + label: `${stats.perMapBreakdown[1].scrimName} - ${stats.perMapBreakdown[1].mapName}`, + color: "var(--chart-2)", + }, + } + : { + value: { + label: t("averagePerformance"), + color: "var(--chart-1)", + }, + }; + + return ( +
+ {/* Line Chart - Progression (Multi-map only) */} + {viewMode === "multi-map" && ( + + + {t("performanceProgression")} + + + + + + + + ( + + )} + /> + } /> + + + + + + + + + + + + + + )} + + {/* Bar Chart - Side by Side (Two-map only) */} + {viewMode === "two-map" && barChartData.length > 0 && ( + + + {t("statComparison")} + + + + + + + + } /> + } /> + + + + + + + )} + + {/* Radar Chart - Performance Profile */} + {radarChartData && radarChartData.length > 0 ? ( + + + {t("performanceProfile")} + + + + + } + /> + + + + {viewMode === "two-map" && + stats.perMapBreakdown?.length === 2 ? ( + <> + + + + ) : ( + + )} + + + + + ) : null} + + {/* Impact Metrics Cards - Normalized Stats Only */} +
+ + + + {t("firstPickPercentage")} + + + +
+ {stats.aggregated.firstPickPercentage.toFixed(1)}% +
+

+ {stats.aggregated.firstPicksPer10.toFixed(1)} {t("per10")} +

+
+
+ + + + + {t("firstDeathPercentage")} + + + +
+ {stats.aggregated.firstDeathPercentage.toFixed(1)}% +
+

+ {stats.aggregated.firstDeathsPer10.toFixed(1)} {t("per10")} +

+
+
+ + + + + {t("killsPerUltimate")} + + + +
+ {stats.aggregated.killsPerUltimate.toFixed(2)} +
+

{t("avgUltImpact")}

+
+
+ + + + + {t("soloKillsPer10")} + + + +
+ {stats.aggregated.soloKillsPer10.toFixed(1)} +
+

+ {t("individualKills")} +

+
+
+ + + + + {t("damageTakenPer10")} + + + +
+ {stats.aggregated.damageTakenPer10.toLocaleString()} +
+

+ {t("damageReceived")} +

+
+
+ + + + + {t("healingReceivedPer10")} + + + +
+ {stats.aggregated.healingReceivedPer10.toLocaleString()} +
+

+ {t("supportReceived")} +

+
+
+ + + + + {t("fightReversalPercentage")} + + + +
+ {stats.aggregated.fightReversalPercentage.toFixed(1)}% +
+

{t("clutchPlays")}

+
+
+
+
+ ); +} diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx new file mode 100644 index 000000000..2101a5179 --- /dev/null +++ b/src/components/compare/comparison-content.tsx @@ -0,0 +1,551 @@ +"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 { FormattedMapGroup } from "@/types/map-group"; +import type { TeamComparisonStats } from "@/types/team-comparison"; +import { useQuery } from "@tanstack/react-query"; +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"; +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 { 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"; +import { TrendsView } from "./trends-view"; + +type ComparisonContentProps = { + teamId: number; + locale: string; +}; + +type ViewMode = + | "side-by-side" + | "delta" + | "trends" + | "charts" + | "consistency" + | "detailed-stats" + | "impact-metrics"; +type ComparisonMode = "player" | "team"; +type MapSelectionMode = "individual" | "groups"; + +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; +} + +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; +} + +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 - 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); + const [selectedHeroes, setSelectedHeroes] = useState([]); + const [dateRange, setDateRange] = useState< + { from: Date; to: Date } | undefined + >(undefined); + + // Comparison mode state (player vs team) + 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", + effectiveMapIds, + selectedPlayer, + selectedHeroes, + ], + queryFn: () => + fetchComparisonStats( + effectiveMapIds, + selectedPlayer!, + selectedHeroes.length > 0 ? selectedHeroes : undefined + ), + enabled: + comparisonMode === "player" && + effectiveMapIds.length > 0 && + !!selectedPlayer, + staleTime: 5 * 60 * 1000, + }); + + // Fetch team comparison stats (team mode) + const { data: teamComparisonStats, isLoading: isLoadingTeam } = useQuery({ + queryKey: ["teamComparisonStats", effectiveMapIds, teamId, selectedHeroes], + queryFn: () => + fetchTeamComparisonStats( + effectiveMapIds, + teamId, + selectedHeroes.length > 0 ? selectedHeroes : undefined + ), + enabled: comparisonMode === "team" && effectiveMapIds.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 effectiveMapIds.length >= 2 ? ["side-by-side"] : []; + } + + // Player comparison views + if (effectiveMapIds.length === 2) { + return [ + "side-by-side", + "delta", + "charts", + "consistency", + "detailed-stats", + "impact-metrics", + ]; + } else if (effectiveMapIds.length >= 3) { + return [ + "trends", + "charts", + "consistency", + "detailed-stats", + "impact-metrics", + ]; + } + return []; + }, [effectiveMapIds.length, comparisonMode]); + + // Auto-switch view when map selection or comparison mode changes + useEffect(() => { + if (availableViews.length > 0 && !availableViews.includes(activeView)) { + setActiveView(availableViews[0]); + } + }, [availableViews, activeView]); + + // Empty state when no maps selected (considering both modes) + if (effectiveMapIds.length === 0) { + return ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + {mapSelectionMode === "groups" ? ( + + { + setSelectedMapGroups(groupIds); + }} + /> + + ) : ( + + )} +
+ ); + } + + return ( +
+ {/* Header */} +
+

{t("title")}

+

+ {effectiveMapIds.length > 0 + ? t("comparingMaps", { count: effectiveMapIds.length }) + : t("subtitle")} +

+
+ + {/* Map Selection Mode Toggle */} + +
+
+
+ +

+ {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.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 */} + +
+
+ +

+ {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 */} + {comparisonMode === "player" && !selectedPlayer ? ( + + ) : isLoading ? ( + +
+ + {t("loading")} +
+
+ ) : comparisonMode === "player" && !comparisonStats ? ( + + ) : comparisonMode === "team" && !teamComparisonStats ? ( + + ) : comparisonMode === "team" ? ( + + ) : ( + 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("consistency") && ( + + {t("views.consistency")} + + )} + {availableViews.includes("detailed-stats") && ( + + {t("views.detailedStats")} + + )} + {availableViews.includes("impact-metrics") && ( + + {t("views.impactMetrics")} + + )} + + + {availableViews.includes("side-by-side") && ( + + + + )} + + {availableViews.includes("delta") && ( + + + + )} + + {availableViews.includes("trends") && ( + + + + )} + + {availableViews.includes("charts") && ( + + + + )} + + {availableViews.includes("consistency") && ( + + + + )} + + {availableViews.includes("detailed-stats") && ( + + + + )} + + {availableViews.includes("impact-metrics") && ( + + + + )} + + )} +
+ ); +} diff --git a/src/components/compare/comparison-filters.tsx b/src/components/compare/comparison-filters.tsx new file mode 100644 index 000000000..936c38eb0 --- /dev/null +++ b/src/components/compare/comparison-filters.tsx @@ -0,0 +1,116 @@ +"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 { Label } from "@/components/ui/label"; +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; + mapIds: 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, + mapIds, + 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([])} + /> + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/compare/consistency-view.tsx b/src/components/compare/consistency-view.tsx new file mode 100644 index 000000000..cc450e473 --- /dev/null +++ b/src/components/compare/consistency-view.tsx @@ -0,0 +1,521 @@ +"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", + }; +} + +const METRIC_LABELS: Record = { + elimsPer10: "Eliminations", + deathsPer10: "Deaths", + damagePer10: "Damage (k)", + healingPer10: "Healing (k)", +}; + +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) => { + const label = + METRIC_LABELS[entry.dataKey as string] || + entry.name; + return ( +
+
+
+ + {label}: + +
+ + {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. +

+
+
+ + +
+ ); +} diff --git a/src/components/compare/delta-view.tsx b/src/components/compare/delta-view.tsx new file mode 100644 index 000000000..666661dcd --- /dev/null +++ b/src/components/compare/delta-view.tsx @@ -0,0 +1,322 @@ +"use client"; + +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 { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; + +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.all_damage_dealt ?? 0, + newValue: map2.stats.all_damage_dealt ?? 0, + format: "number", + }, + { + label: t("stats.healing"), + oldValue: map1.stats.healing_dealt ?? 0, + newValue: map2.stats.healing_dealt ?? 0, + format: "number", + }, + { + label: t("stats.mitigated"), + oldValue: map1.stats.damage_blocked ?? 0, + newValue: map2.stats.damage_blocked ?? 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.allDamagePer10 ?? 0, + newValue: map2.stats.allDamagePer10 ?? 0, + format: "per10", + }, + { + label: t("stats.healingPer10"), + oldValue: map1.stats.healingDealtPer10 ?? 0, + newValue: map2.stats.healingDealtPer10 ?? 0, + format: "per10", + }, + { + label: t("stats.mitigatedPer10"), + oldValue: map1.stats.damageBlockedPer10 ?? 0, + newValue: map2.stats.damageBlockedPer10 ?? 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")} +

+ )} +
+
+
+
+ ); +} 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) +

+
+
+
+ ); +} diff --git a/src/components/compare/empty-state.tsx b/src/components/compare/empty-state.tsx new file mode 100644 index 000000000..4f04d72b1 --- /dev/null +++ b/src/components/compare/empty-state.tsx @@ -0,0 +1,41 @@ +"use client"; + +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, + children, +}: EmptyStateProps) { + const Icon = + icon === "MapPin" + ? MapPin + : icon === "UserX" + ? UserX + : icon === "TrendingDown" + ? TrendingDown + : Loader2; + + return ( + + + +
+

{title}

+

{description}

+
+ {children &&
{children}
} +
+
+ ); +} 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)} +

+ +
+ ); + })} +
+
+
+
+ ); + })} +
+ ); +} 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")} +

+ +
+
+ ); +} diff --git a/src/components/compare/map-group-manager.tsx b/src/components/compare/map-group-manager.tsx new file mode 100644 index 000000000..fefe51c49 --- /dev/null +++ b/src/components/compare/map-group-manager.tsx @@ -0,0 +1,512 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { FormattedMapGroup } from "@/types/map-group"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + FolderPlus, + Loader2, + MoreVertical, + Pencil, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +type MapGroupManagerProps = { + teamId: number; + availableMaps: { + id: number; + name: string; + scrimName: string; + }[]; +}; + +type MapGroupFormData = { + name: string; + description: string; + category: string; + mapIds: number[]; +}; + +async function fetchMapGroups(teamId: number): Promise { + const response = await fetch(`/api/compare/map-groups?teamId=${teamId}`); + if (!response.ok) { + throw new Error("Failed to fetch map groups"); + } + const data = (await response.json()) as { + success: boolean; + groups: FormattedMapGroup[]; + }; + return data.groups; +} + +async function createMapGroup( + teamId: number, + formData: MapGroupFormData +): Promise { + const response = await fetch("/api/compare/map-groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...formData, + teamId, + }), + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to create map group"); + } + const data = (await response.json()) as { + success: boolean; + group: FormattedMapGroup; + }; + return data.group; +} + +async function updateMapGroup( + groupId: number, + formData: Partial +): Promise { + const response = await fetch(`/api/compare/map-groups/${groupId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to update map group"); + } + const data = (await response.json()) as { + success: boolean; + group: FormattedMapGroup; + }; + return data.group; +} + +async function deleteMapGroup(groupId: number): Promise { + const response = await fetch(`/api/compare/map-groups/${groupId}`, { + method: "DELETE", + }); + if (!response.ok) { + const error = (await response.json()) as { error: string }; + throw new Error(error.error || "Failed to delete map group"); + } +} + +function MapGroupForm({ + teamId, + availableMaps, + onSuccess, + editGroup, +}: { + teamId: number; + availableMaps: MapGroupManagerProps["availableMaps"]; + onSuccess: () => void; + editGroup?: FormattedMapGroup; +}) { + const queryClient = useQueryClient(); + + const [formData, setFormData] = useState({ + name: editGroup?.name ?? "", + description: editGroup?.description ?? "", + category: editGroup?.category ?? "", + mapIds: editGroup?.mapIds ?? [], + }); + + const createMutation = useMutation({ + mutationFn: () => createMapGroup(teamId, formData), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["mapGroups", teamId] }); + toast.success("Map group created", { + description: "Your map group has been created successfully.", + }); + onSuccess(); + }, + onError: (error: Error) => { + toast.error("Error", { + description: error.message, + }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: () => updateMapGroup(editGroup!.id, formData), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["mapGroups", teamId] }); + toast.success("Map group updated", { + description: "Your map group has been updated successfully.", + }); + onSuccess(); + }, + onError: (error: Error) => { + toast.error("Error", { + description: error.message, + }); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (editGroup) { + updateMutation.mutate(); + } else { + createMutation.mutate(); + } + } + + function toggleMapSelection(mapId: number) { + setFormData((prev) => ({ + ...prev, + mapIds: prev.mapIds.includes(mapId) + ? prev.mapIds.filter((id) => id !== mapId) + : [...prev.mapIds, mapId], + })); + } + + const isLoading = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + required + disabled={isLoading} + /> +
+ +
+ +