From cd98947ab695a2a9525afbf3ee562e46ac3e004f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Mon, 23 Feb 2026 20:02:41 -0500 Subject: [PATCH 001/153] Implement scouting navigation in MainNav component --- src/components/dashboard/main-nav.tsx | 49 +++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/main-nav.tsx b/src/components/dashboard/main-nav.tsx index f7e9cbd97..bbe32097e 100644 --- a/src/components/dashboard/main-nav.tsx +++ b/src/components/dashboard/main-nav.tsx @@ -9,7 +9,6 @@ import { NavigationMenuList, NavigationMenuTrigger, } from "@/components/ui/navigation-menu"; -import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; @@ -17,12 +16,11 @@ import { usePathname } from "next/navigation"; export function MainNav({ className }: React.HTMLAttributes) { const pathname = usePathname(); const t = useTranslations("dashboard.mainNav"); - const isMobile = useIsMobile(); return ( @@ -136,6 +134,51 @@ export function MainNav({ className }: React.HTMLAttributes) { {t("teams")} + + + {t("scouting")} + + +
    +
  • + + + {t("scoutPlayer")} + + + + + {t("scoutTeam")} + + +
  • +
+
+
Date: Mon, 23 Feb 2026 20:02:47 -0500 Subject: [PATCH 002/153] Add scouting-related translations to English JSON file --- messages/en.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 4dc4977f0..5e6a9adbf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -378,7 +378,10 @@ "playerStats": "Player Stats", "heroStats": "Hero Stats", "compareStats": "Compare Players", - "teamStats": "Team Stats" + "teamStats": "Team Stats", + "scouting": "Scouting", + "scoutPlayer": "Scout a player", + "scoutTeam": "Scout a team" }, "teamSwitcher": { "searchTeamPlaceholder": "Search team...", From 0c52ff9f3294e78e2fd4bfe3d9af18535bbd56ab Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 10:30:00 -0500 Subject: [PATCH 003/153] Add scouting tool flag to enable or disable the feature in the application --- src/lib/flags.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/flags.ts b/src/lib/flags.ts index a47f4f22e..b2e72a595 100644 --- a/src/lib/flags.ts +++ b/src/lib/flags.ts @@ -72,3 +72,21 @@ export const overviewCard = flag({ description: "Enable or disable an overview card for the scrim", identify, }); + +export const scoutingTool = flag({ + key: "scouting-tool", + adapter: vercelAdapter(), + options: [ + { + value: true, + label: "Enabled", + }, + { + value: false, + label: "Disabled", + }, + ], + defaultValue: false, + description: "Enable or disable the scouting tool", + identify, +}); From b0b842d57b78dadc248a1e41f804045a255dadac Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 10:30:19 -0500 Subject: [PATCH 004/153] Integrate scouting feature into MainNav component, conditionally rendering navigation items based on scouting tool flag --- src/components/dashboard-layout.tsx | 8 ++- src/components/dashboard/main-nav.tsx | 98 ++++++++++++++------------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/src/components/dashboard-layout.tsx b/src/components/dashboard-layout.tsx index 3c3399966..0263fd93f 100644 --- a/src/components/dashboard-layout.tsx +++ b/src/components/dashboard-layout.tsx @@ -11,6 +11,7 @@ import { ModeToggle } from "@/components/theme-switcher"; import { UserNav } from "@/components/user-nav"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { scoutingTool } from "@/lib/flags"; export async function DashboardLayout({ children, @@ -22,13 +23,18 @@ export async function DashboardLayout({ const session = await auth(); const user = await getUser(session?.user?.email); + const scoutingEnabled = await scoutingTool(); + return (
- +
diff --git a/src/components/dashboard/main-nav.tsx b/src/components/dashboard/main-nav.tsx index bbe32097e..5f938ab81 100644 --- a/src/components/dashboard/main-nav.tsx +++ b/src/components/dashboard/main-nav.tsx @@ -13,7 +13,10 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; -export function MainNav({ className }: React.HTMLAttributes) { +export function MainNav({ + scoutingEnabled, + className, +}: React.HTMLAttributes & { scoutingEnabled: boolean }) { const pathname = usePathname(); const t = useTranslations("dashboard.mainNav"); @@ -134,51 +137,54 @@ export function MainNav({ className }: React.HTMLAttributes) { {t("teams")} - - - {t("scouting")} - - -
    -
  • - - - {t("scoutPlayer")} - - - - - {t("scoutTeam")} - - -
  • -
-
-
+ {scoutingEnabled && ( + + + {t("scouting")} + + +
    +
  • + + + {t("scoutPlayer")} + + + + + {t("scoutTeam")} + + +
  • +
+
+
+ )} Date: Tue, 24 Feb 2026 16:15:13 -0500 Subject: [PATCH 005/153] Update PlayerRow component to use playerKey for unique identification and improve player name handling --- src/components/scrim/scrim-overview-card.tsx | 7 ++++--- src/data/scrim-overview-dto.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index d99d89876..7c2688485 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -212,12 +212,13 @@ function OutlierBadge({ function PlayerRow({ player }: { player: PlayerScrimPerformance }) { const topOutliers = player.outliers.slice(0, 2); const hasChartData = player.perMapPerformance.length >= 2; + const playerDisplayName = player.playerName.trim() || "Unknown Player"; return ( @@ -238,7 +239,7 @@ function PlayerRow({ player }: { player: PlayerScrimPerformance }) {

- {player.playerName} + {playerDisplayName}

{player.primaryHero} @@ -405,7 +406,7 @@ export async function ScrimOverviewCard({ {teamPlayers.map((player) => ( - + ))} diff --git a/src/data/scrim-overview-dto.ts b/src/data/scrim-overview-dto.ts index 3c8770a85..42e4b0265 100644 --- a/src/data/scrim-overview-dto.ts +++ b/src/data/scrim-overview-dto.ts @@ -45,6 +45,7 @@ export type PlayerMapPerformance = { }; export type PlayerScrimPerformance = { + playerKey: string; playerName: string; primaryHero: HeroName; heroes: HeroName[]; @@ -919,6 +920,10 @@ async function getScrimOverviewFn( ? calculateTrends(perMapStats, perMapCalculatedStats) : undefined; const trend = determineTrend(trendData); + const rowId = playerRows.reduce( + (lowest, row) => (row.id < lowest ? row.id : lowest), + playerRows[0].id + ); const kdRatio = aggregated.deaths > 0 @@ -926,6 +931,7 @@ async function getScrimOverviewFn( : aggregated.eliminations; basePlayers.push({ + playerKey: `${playerName}:${rowId}`, playerName, primaryHero, heroes, From 3de59aff0aa3361625f7f493974d9c119467b8ac Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 16:25:23 -0500 Subject: [PATCH 006/153] Refactor PlayerPerformanceHoverChart and PlayerRow components to enhance performance data validation and improve player identity handling --- .../scrim/player-performance-hover-chart.tsx | 280 +++++++++--------- src/components/scrim/scrim-overview-card.tsx | 102 ++++--- 2 files changed, 210 insertions(+), 172 deletions(-) diff --git a/src/components/scrim/player-performance-hover-chart.tsx b/src/components/scrim/player-performance-hover-chart.tsx index 2da00d39b..2e9510fc3 100644 --- a/src/components/scrim/player-performance-hover-chart.tsx +++ b/src/components/scrim/player-performance-hover-chart.tsx @@ -96,161 +96,165 @@ export function PlayerPerformanceHoverChart({ perMapPerformance, children, }: Props) { - if (perMapPerformance.length < 2) { - return children; - } + try { + if (perMapPerformance.length < 2) { + return children; + } - const role = heroRoleMapping[primaryHero]; - const isSupport = role === "Support"; - const thirdStatLabel = isSupport ? "Healing/10" : "Dmg/10"; + const role = heroRoleMapping[primaryHero]; + const isSupport = role === "Support"; + const thirdStatLabel = isSupport ? "Healing/10" : "Dmg/10"; - const len = perMapPerformance.length; - const avgKd = perMapPerformance.reduce((sum, m) => sum + m.kdRatio, 0) / len; - const avgElims = - perMapPerformance.reduce((sum, m) => sum + m.eliminationsPer10, 0) / len; - const avgThirdStat = isSupport - ? perMapPerformance.reduce((sum, m) => sum + m.healingDealtPer10, 0) / len - : perMapPerformance.reduce((sum, m) => sum + m.heroDamagePer10, 0) / len; - const avgFirstDeath = - perMapPerformance.reduce((sum, m) => sum + m.firstDeathRate, 0) / len; - const avgTeamFirstDeath = - perMapPerformance.reduce((sum, m) => sum + m.teamFirstDeathRate, 0) / len; + const len = perMapPerformance.length; + const avgKd = perMapPerformance.reduce((sum, m) => sum + m.kdRatio, 0) / len; + const avgElims = + perMapPerformance.reduce((sum, m) => sum + m.eliminationsPer10, 0) / len; + const avgThirdStat = isSupport + ? perMapPerformance.reduce((sum, m) => sum + m.healingDealtPer10, 0) / len + : perMapPerformance.reduce((sum, m) => sum + m.heroDamagePer10, 0) / len; + const avgFirstDeath = + perMapPerformance.reduce((sum, m) => sum + m.firstDeathRate, 0) / len; + const avgTeamFirstDeath = + perMapPerformance.reduce((sum, m) => sum + m.teamFirstDeathRate, 0) / len; - const chartData = perMapPerformance.map((m) => ({ - map: m.mapName, - kd: avgKd > 0 ? (m.kdRatio / avgKd) * 100 : 0, - elims: avgElims > 0 ? (m.eliminationsPer10 / avgElims) * 100 : 0, - thirdStat: - avgThirdStat > 0 - ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / - avgThirdStat) * - 100 - : 0, - firstDeath: - avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, - teamFirstDeath: - avgTeamFirstDeath > 0 - ? (m.teamFirstDeathRate / avgTeamFirstDeath) * 100 - : 0, - rawKd: m.kdRatio, - rawElims: m.eliminationsPer10, - rawThirdStat: isSupport ? m.healingDealtPer10 : m.heroDamagePer10, - rawFirstDeath: m.firstDeathRate, - rawTeamFirstDeath: m.teamFirstDeathRate, - })); + const chartData = perMapPerformance.map((m) => ({ + map: m.mapName, + kd: avgKd > 0 ? (m.kdRatio / avgKd) * 100 : 0, + elims: avgElims > 0 ? (m.eliminationsPer10 / avgElims) * 100 : 0, + thirdStat: + avgThirdStat > 0 + ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / + avgThirdStat) * + 100 + : 0, + firstDeath: + avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, + teamFirstDeath: + avgTeamFirstDeath > 0 + ? (m.teamFirstDeathRate / avgTeamFirstDeath) * 100 + : 0, + rawKd: m.kdRatio, + rawElims: m.eliminationsPer10, + rawThirdStat: isSupport ? m.healingDealtPer10 : m.heroDamagePer10, + rawFirstDeath: m.firstDeathRate, + rawTeamFirstDeath: m.teamFirstDeathRate, + })); - const chartConfig = { - kd: { label: "K/D", color: "#3b82f6" }, - elims: { label: "Elims/10", color: "#10b981" }, - thirdStat: { label: thirdStatLabel, color: "#f59e0b" }, - firstDeath: { label: "1st Death %", color: "#f43f5e" }, - teamFirstDeath: { label: "Team 1st Death %", color: "#8b5cf6" }, - } satisfies ChartConfig; + const chartConfig = { + kd: { label: "K/D", color: "#3b82f6" }, + elims: { label: "Elims/10", color: "#10b981" }, + thirdStat: { label: thirdStatLabel, color: "#f59e0b" }, + firstDeath: { label: "1st Death %", color: "#f43f5e" }, + teamFirstDeath: { label: "Team 1st Death %", color: "#8b5cf6" }, + } satisfies ChartConfig; - return ( - - {children} - -

-
-

{playerName}

-

- Performance across maps (% of avg) -

-
- - - - - `${Math.round(value)}%`} - /> - - } /> - - - - {avgFirstDeath > 0 && ( + return ( + + {children} + +
+
+

{playerName}

+

+ Performance across maps (% of avg) +

+
+ + + + + `${Math.round(value)}%`} + /> + + } /> - )} - {avgTeamFirstDeath > 0 && ( - )} - - -
- {Object.entries(chartConfig).map(([key, config]) => ( -
-
- - {String(config.label)} - -
- ))} + {avgFirstDeath > 0 && ( + + )} + {avgTeamFirstDeath > 0 && ( + + )} + + +
+ {Object.entries(chartConfig).map(([key, config]) => ( +
+
+ + {String(config.label)} + +
+ ))} +
-
- - - ); + + + ); + } catch { + return children; + } } diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index 7c2688485..9f1fd3363 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -209,47 +209,81 @@ function OutlierBadge({ ); } +function toDisplayText(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +function toSafeHeroImageSlug(heroName: string): string { + try { + const slug = toHero(heroName).trim(); + return slug.length > 0 ? slug : "ana"; + } catch { + return "ana"; + } +} + +function hasValidPerMapPerformance( + perMapPerformance: PlayerScrimPerformance["perMapPerformance"] +): boolean { + return perMapPerformance.every( + (map) => + Number.isFinite(map.kdRatio) && + Number.isFinite(map.eliminationsPer10) && + Number.isFinite(map.heroDamagePer10) && + Number.isFinite(map.healingDealtPer10) && + Number.isFinite(map.firstDeathRate) && + Number.isFinite(map.teamFirstDeathRate) + ); +} + function PlayerRow({ player }: { player: PlayerScrimPerformance }) { const topOutliers = player.outliers.slice(0, 2); - const hasChartData = player.perMapPerformance.length >= 2; - const playerDisplayName = player.playerName.trim() || "Unknown Player"; + const heroCount = Array.isArray(player.heroes) ? player.heroes.length : 0; + const hasChartData = + player.perMapPerformance.length >= 2 && + hasValidPerMapPerformance(player.perMapPerformance); + const playerDisplayName = toDisplayText(player.playerName, "Unknown Player"); + const primaryHeroDisplay = toDisplayText(player.primaryHero, "Unknown Hero"); + const heroImageSlug = toSafeHeroImageSlug(primaryHeroDisplay); + const playerIdentity = ( +
+
+ {primaryHeroDisplay} +
+
+

{playerDisplayName}

+

+ {primaryHeroDisplay} + {heroCount > 1 && +{heroCount - 1}} +

+
+
+ ); return ( - -
-
- {player.primaryHero} -
-
-

- {playerDisplayName} -

-

- {player.primaryHero} - {player.heroes.length > 1 && ( - +{player.heroes.length - 1} - )} -

-
-
-
+ {playerIdentity} + + ) : ( + playerIdentity + )}
{player.mapsPlayed} From 06f3a692afc6ab82f883f76948f566149f8da955 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 16:37:39 -0500 Subject: [PATCH 007/153] Test new bug fixing approach --- .../scrim/player-performance-hover-chart.tsx | 213 ++++++++++++------ 1 file changed, 144 insertions(+), 69 deletions(-) diff --git a/src/components/scrim/player-performance-hover-chart.tsx b/src/components/scrim/player-performance-hover-chart.tsx index 2e9510fc3..4a460c39d 100644 --- a/src/components/scrim/player-performance-hover-chart.tsx +++ b/src/components/scrim/player-performance-hover-chart.tsx @@ -1,5 +1,6 @@ "use client"; +import * as React from "react"; import type { ChartConfig } from "@/components/ui/chart"; import { ChartContainer } from "@/components/ui/chart"; import { @@ -39,6 +40,14 @@ type ChartDataPoint = { rawTeamFirstDeath: number; }; +type ChartModel = { + thirdStatLabel: string; + avgFirstDeath: number; + avgTeamFirstDeath: number; + chartData: ChartDataPoint[]; + chartConfig: ChartConfig; +}; + type Props = { playerName: string; primaryHero: HeroName; @@ -46,6 +55,42 @@ type Props = { children: React.ReactNode; }; +type ChartErrorBoundaryProps = { + fallback: React.ReactNode; + children: React.ReactNode; +}; + +type ChartErrorBoundaryState = { + hasError: boolean; +}; + +class ChartErrorBoundary extends React.Component< + ChartErrorBoundaryProps, + ChartErrorBoundaryState +> { + public constructor(props: ChartErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + public static getDerivedStateFromError(): ChartErrorBoundaryState { + return { hasError: true }; + } + + public componentDidUpdate(prevProps: ChartErrorBoundaryProps): void { + if (prevProps.children !== this.props.children && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + public render(): React.ReactNode { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + function formatRawValue( dataKey: string | number | undefined, point: ChartDataPoint @@ -90,78 +135,110 @@ function PerformanceTooltip({ ); } +function buildChartModel( + primaryHero: HeroName, + perMapPerformance: PlayerMapPerformance[] +): ChartModel { + const role = heroRoleMapping[primaryHero]; + const isSupport = role === "Support"; + const thirdStatLabel = isSupport ? "Healing/10" : "Dmg/10"; + + const len = perMapPerformance.length; + const avgKd = perMapPerformance.reduce((sum, m) => sum + m.kdRatio, 0) / len; + const avgElims = + perMapPerformance.reduce((sum, m) => sum + m.eliminationsPer10, 0) / len; + const avgThirdStat = isSupport + ? perMapPerformance.reduce((sum, m) => sum + m.healingDealtPer10, 0) / len + : perMapPerformance.reduce((sum, m) => sum + m.heroDamagePer10, 0) / len; + const avgFirstDeath = + perMapPerformance.reduce((sum, m) => sum + m.firstDeathRate, 0) / len; + const avgTeamFirstDeath = + perMapPerformance.reduce((sum, m) => sum + m.teamFirstDeathRate, 0) / len; + + const chartData = perMapPerformance.map((m) => ({ + map: m.mapName, + kd: avgKd > 0 ? (m.kdRatio / avgKd) * 100 : 0, + elims: avgElims > 0 ? (m.eliminationsPer10 / avgElims) * 100 : 0, + thirdStat: + avgThirdStat > 0 + ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / avgThirdStat) * + 100 + : 0, + firstDeath: avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, + teamFirstDeath: + avgTeamFirstDeath > 0 + ? (m.teamFirstDeathRate / avgTeamFirstDeath) * 100 + : 0, + rawKd: m.kdRatio, + rawElims: m.eliminationsPer10, + rawThirdStat: isSupport ? m.healingDealtPer10 : m.heroDamagePer10, + rawFirstDeath: m.firstDeathRate, + rawTeamFirstDeath: m.teamFirstDeathRate, + })); + + const chartConfig = { + kd: { label: "K/D", color: "#3b82f6" }, + elims: { label: "Elims/10", color: "#10b981" }, + thirdStat: { label: thirdStatLabel, color: "#f59e0b" }, + firstDeath: { label: "1st Death %", color: "#f43f5e" }, + teamFirstDeath: { label: "Team 1st Death %", color: "#8b5cf6" }, + } satisfies ChartConfig; + + return { thirdStatLabel, avgFirstDeath, avgTeamFirstDeath, chartData, chartConfig }; +} + export function PlayerPerformanceHoverChart({ playerName, primaryHero, perMapPerformance, children, }: Props) { + if (perMapPerformance.length < 2) { + return children; + } + let model: ChartModel; try { - if (perMapPerformance.length < 2) { - return children; - } - - const role = heroRoleMapping[primaryHero]; - const isSupport = role === "Support"; - const thirdStatLabel = isSupport ? "Healing/10" : "Dmg/10"; - - const len = perMapPerformance.length; - const avgKd = perMapPerformance.reduce((sum, m) => sum + m.kdRatio, 0) / len; - const avgElims = - perMapPerformance.reduce((sum, m) => sum + m.eliminationsPer10, 0) / len; - const avgThirdStat = isSupport - ? perMapPerformance.reduce((sum, m) => sum + m.healingDealtPer10, 0) / len - : perMapPerformance.reduce((sum, m) => sum + m.heroDamagePer10, 0) / len; - const avgFirstDeath = - perMapPerformance.reduce((sum, m) => sum + m.firstDeathRate, 0) / len; - const avgTeamFirstDeath = - perMapPerformance.reduce((sum, m) => sum + m.teamFirstDeathRate, 0) / len; - - const chartData = perMapPerformance.map((m) => ({ - map: m.mapName, - kd: avgKd > 0 ? (m.kdRatio / avgKd) * 100 : 0, - elims: avgElims > 0 ? (m.eliminationsPer10 / avgElims) * 100 : 0, - thirdStat: - avgThirdStat > 0 - ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / - avgThirdStat) * - 100 - : 0, - firstDeath: - avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, - teamFirstDeath: - avgTeamFirstDeath > 0 - ? (m.teamFirstDeathRate / avgTeamFirstDeath) * 100 - : 0, - rawKd: m.kdRatio, - rawElims: m.eliminationsPer10, - rawThirdStat: isSupport ? m.healingDealtPer10 : m.heroDamagePer10, - rawFirstDeath: m.firstDeathRate, - rawTeamFirstDeath: m.teamFirstDeathRate, - })); - - const chartConfig = { - kd: { label: "K/D", color: "#3b82f6" }, - elims: { label: "Elims/10", color: "#10b981" }, - thirdStat: { label: thirdStatLabel, color: "#f59e0b" }, - firstDeath: { label: "1st Death %", color: "#f43f5e" }, - teamFirstDeath: { label: "Team 1st Death %", color: "#8b5cf6" }, - } satisfies ChartConfig; - + model = buildChartModel(primaryHero, perMapPerformance); + } catch (error) { + console.error("[scrim-overview] player hover chart model failed", { + playerName, + primaryHero, + perMapPerformance, + error, + }); return ( {children} -
-
-

{playerName}

+

+ Performance chart unavailable for this player. +

+ + + ); + } + + return ( + + {children} + +
+
+

{playerName}

+

+ Performance across maps (% of avg) +

+
+ - Performance across maps (% of avg) + Performance chart unavailable for this player.

-
- + } + > + @@ -204,14 +281,14 @@ export function PlayerPerformanceHoverChart({ /> - {avgFirstDeath > 0 && ( + {model.avgFirstDeath > 0 && ( )} - {avgTeamFirstDeath > 0 && ( + {model.avgTeamFirstDeath > 0 && (
- {Object.entries(chartConfig).map(([key, config]) => ( + {Object.entries(model.chartConfig).map(([key, config]) => (
))}
-
- - - ); - } catch { - return children; - } + +
+
+
+ ); } From 7f66708a4289ee8e849103d27320706017c0969f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 16:51:09 -0500 Subject: [PATCH 008/153] Refactor PlayerPerformanceHoverChart and ScrimOverviewCard components to improve player identity display and enhance chart integration --- .../scrim/player-performance-hover-chart.tsx | 80 ++++++--- src/components/scrim/scrim-overview-card.tsx | 167 +++++++----------- 2 files changed, 126 insertions(+), 121 deletions(-) diff --git a/src/components/scrim/player-performance-hover-chart.tsx b/src/components/scrim/player-performance-hover-chart.tsx index 4a460c39d..7ff4ae037 100644 --- a/src/components/scrim/player-performance-hover-chart.tsx +++ b/src/components/scrim/player-performance-hover-chart.tsx @@ -1,6 +1,5 @@ "use client"; -import * as React from "react"; import type { ChartConfig } from "@/components/ui/chart"; import { ChartContainer } from "@/components/ui/chart"; import { @@ -9,8 +8,11 @@ import { HoverCardTrigger, } from "@/components/ui/hover-card"; import type { PlayerMapPerformance } from "@/data/scrim-overview-dto"; +import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import { heroRoleMapping } from "@/types/heroes"; +import Image from "next/image"; +import * as React from "react"; import { CartesianGrid, Line, @@ -51,8 +53,10 @@ type ChartModel = { type Props = { playerName: string; primaryHero: HeroName; + heroLabel: string; + heroImageSlug: string; + heroCount: number; perMapPerformance: PlayerMapPerformance[]; - children: React.ReactNode; }; type ChartErrorBoundaryProps = { @@ -161,10 +165,12 @@ function buildChartModel( elims: avgElims > 0 ? (m.eliminationsPer10 / avgElims) * 100 : 0, thirdStat: avgThirdStat > 0 - ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / avgThirdStat) * + ? ((isSupport ? m.healingDealtPer10 : m.heroDamagePer10) / + avgThirdStat) * 100 : 0, - firstDeath: avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, + firstDeath: + avgFirstDeath > 0 ? (m.firstDeathRate / avgFirstDeath) * 100 : 0, teamFirstDeath: avgTeamFirstDeath > 0 ? (m.teamFirstDeathRate / avgTeamFirstDeath) * 100 @@ -184,43 +190,72 @@ function buildChartModel( teamFirstDeath: { label: "Team 1st Death %", color: "#8b5cf6" }, } satisfies ChartConfig; - return { thirdStatLabel, avgFirstDeath, avgTeamFirstDeath, chartData, chartConfig }; + return { + thirdStatLabel, + avgFirstDeath, + avgTeamFirstDeath, + chartData, + chartConfig, + }; } export function PlayerPerformanceHoverChart({ playerName, primaryHero, + heroLabel, + heroImageSlug, + heroCount, perMapPerformance, - children, }: Props) { + const identity = ( +
+
+ {heroLabel} +
+
+

{playerName}

+

+ {heroLabel} + {heroCount > 1 && +{heroCount - 1}} +

+
+
+ ); + if (perMapPerformance.length < 2) { - return children; + return identity; } + let model: ChartModel; try { model = buildChartModel(primaryHero, perMapPerformance); } catch (error) { - console.error("[scrim-overview] player hover chart model failed", { + Logger.error("[scrim-overview] player hover chart model failed", { playerName, primaryHero, perMapPerformance, error, }); - return ( - - {children} - -

- Performance chart unavailable for this player. -

-
-
- ); + return identity; } return ( - {children} + + +
@@ -236,7 +271,10 @@ export function PlayerPerformanceHoverChart({

} > - + - {String(config.label)} + {typeof config.label === "string" ? config.label : ""}
))} diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index 9f1fd3363..afe8fe950 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -8,14 +8,6 @@ import { CardTitle, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Tooltip, TooltipContent, @@ -36,7 +28,6 @@ import { MinusIcon, StarFilledIcon, } from "@radix-ui/react-icons"; -import Image from "next/image"; type ScrimOverviewCardProps = { scrimId: number; @@ -224,91 +215,49 @@ function toSafeHeroImageSlug(heroName: string): string { } } -function hasValidPerMapPerformance( - perMapPerformance: PlayerScrimPerformance["perMapPerformance"] -): boolean { - return perMapPerformance.every( - (map) => - Number.isFinite(map.kdRatio) && - Number.isFinite(map.eliminationsPer10) && - Number.isFinite(map.heroDamagePer10) && - Number.isFinite(map.healingDealtPer10) && - Number.isFinite(map.firstDeathRate) && - Number.isFinite(map.teamFirstDeathRate) - ); -} - function PlayerRow({ player }: { player: PlayerScrimPerformance }) { const topOutliers = player.outliers.slice(0, 2); const heroCount = Array.isArray(player.heroes) ? player.heroes.length : 0; - const hasChartData = - player.perMapPerformance.length >= 2 && - hasValidPerMapPerformance(player.perMapPerformance); const playerDisplayName = toDisplayText(player.playerName, "Unknown Player"); const primaryHeroDisplay = toDisplayText(player.primaryHero, "Unknown Hero"); const heroImageSlug = toSafeHeroImageSlug(primaryHeroDisplay); - const playerIdentity = ( -
-
- {primaryHeroDisplay} -
-
-

{playerDisplayName}

-

- {primaryHeroDisplay} - {heroCount > 1 && +{heroCount - 1}} -

-
-
- ); return ( - - - {hasChartData ? ( - - {playerIdentity} - - ) : ( - playerIdentity - )} - - + + + + + {player.mapsPlayed} - - + + {player.kdRatio.toFixed(2)} - - + + {player.eliminationsPer10.toFixed(1)} - - + + {player.heroDamagePer10 > 0 ? format(Math.round(player.heroDamagePer10)) : "—"} - - + + {player.firstDeathRate.toFixed(1)}% - - + + {player.teamFirstDeathRate.toFixed(1)}% - - + + - - + +
{topOutliers.length > 0 ? ( topOutliers.map((outlier) => ( @@ -318,8 +267,8 @@ function PlayerRow({ player }: { player: PlayerScrimPerformance }) { )}
-
-
+ + ); } @@ -422,28 +371,46 @@ export async function ScrimOverviewCard({

Player Performance

- - - - Player - Maps - K/D - Elims/10 - Dmg/10 - 1st Death % - +
+
+ + + + + + + + +
+ Player + + Maps + + K/D + + Elims/10 + + Dmg/10 + + 1st Death % + Team 1st Death % - - Trend - Outliers - - - - {teamPlayers.map((player) => ( - - ))} - -
+ + + Trend + + + Outliers + + + + + {teamPlayers.map((player) => ( + + ))} + + +
)} From f1b8e0377c3804b5bc29467e7d817377700c5ac3 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 16:52:15 -0500 Subject: [PATCH 009/153] Reformat --- src/components/scrim/scrim-overview-card.tsx | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index afe8fe950..312160d93 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -224,7 +224,7 @@ function PlayerRow({ player }: { player: PlayerScrimPerformance }) { return ( - + - + {player.mapsPlayed} - + {player.kdRatio.toFixed(2)} - + {player.eliminationsPer10.toFixed(1)} - + {player.heroDamagePer10 > 0 ? format(Math.round(player.heroDamagePer10)) : "—"} - + {player.firstDeathRate.toFixed(1)}% - + {player.teamFirstDeathRate.toFixed(1)}% - + @@ -378,25 +378,25 @@ export async function ScrimOverviewCard({ Player - + Maps - + K/D - + Elims/10 - + Dmg/10 - + 1st Death % - - Team 1st Death % + + Team 1st Death % - + Trend From 27668d2bc20356ccff0b32c4ed6b0cc4dcfa6d38 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 16:56:37 -0500 Subject: [PATCH 010/153] Update MainNav component to conditionally render scouting links based on scoutingEnabled flag --- .../scrim/[scrimId]/map/[mapId]/page.tsx | 66 +++++++++++-------- .../map/[mapId]/player/[playerId]/page.tsx | 8 ++- src/app/demo/page.tsx | 5 +- src/app/demo/player/[playerId]/page.tsx | 5 +- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 3174b8da0..acbd639c7 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -20,6 +20,7 @@ import { VodOverview } from "@/components/vods/vod-overview"; import { getMostPlayedHeroes } from "@/data/player-dto"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { getColorblindMode, translateMapName } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -80,32 +81,40 @@ export default async function MapDashboardPage( const { team1, team2 } = await getColorblindMode(user?.id ?? ""); - const [mostPlayedHeroes, mapDetails, map, visibility, heroBans, noteContent] = - await Promise.all([ - getMostPlayedHeroes(id), - prisma.matchStart.findFirst({ - where: { MapDataId: id }, - select: { map_name: true, team_1_name: true }, - }), - prisma.map.findFirst({ - where: { id }, - select: { replayCode: true, vod: true }, - }), - prisma.scrim.findFirst({ - where: { id: parseInt(params.scrimId) }, - select: { guestMode: true }, - }), - prisma.heroBan.findMany({ - where: { MapDataId: id }, - }), - prisma.note.findFirst({ - where: { - scrimId: parseInt(params.scrimId), - MapDataId: id, - }, - select: { content: true }, - }), - ]); + const [ + mostPlayedHeroes, + mapDetails, + map, + visibility, + heroBans, + noteContent, + scoutingEnabled, + ] = await Promise.all([ + getMostPlayedHeroes(id), + prisma.matchStart.findFirst({ + where: { MapDataId: id }, + select: { map_name: true, team_1_name: true }, + }), + prisma.map.findFirst({ + where: { id }, + select: { replayCode: true, vod: true }, + }), + prisma.scrim.findFirst({ + where: { id: parseInt(params.scrimId) }, + select: { guestMode: true }, + }), + prisma.heroBan.findMany({ + where: { MapDataId: id }, + }), + prisma.note.findFirst({ + where: { + scrimId: parseInt(params.scrimId), + MapDataId: id, + }, + select: { content: true }, + }), + scoutingTool(), + ]); const translatedMapName = await translateMapName( mapDetails?.map_name ?? "Map" @@ -116,7 +125,10 @@ export default async function MapDashboardPage(
- +
diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index 53c82776f..8bc68a81c 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -14,6 +14,7 @@ import { UserNav } from "@/components/user-nav"; import { getMostPlayedHeroes } from "@/data/player-dto"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { toTitleCase } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -85,12 +86,17 @@ export default async function PlayerDashboardPage( }, })) ?? { guestMode: false }; + const scoutingEnabled = await scoutingTool(); + return (
- +
diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 8616e3801..c48b25d7c 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -11,6 +11,7 @@ import { PlayerSwitcher } from "@/components/map/player-switcher"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getMostPlayedHeroes } from "@/data/player-dto"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { toTitleCase, translateMapName } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -89,12 +90,14 @@ export default async function MapDashboardPage() { where: { MapDataId: id }, }); + const scoutingEnabled = await scoutingTool(); + return (
- +
diff --git a/src/app/demo/player/[playerId]/page.tsx b/src/app/demo/player/[playerId]/page.tsx index 640e3c30f..d0b1ea5cb 100644 --- a/src/app/demo/player/[playerId]/page.tsx +++ b/src/app/demo/player/[playerId]/page.tsx @@ -8,6 +8,7 @@ import { DefaultOverview } from "@/components/player/default-overview"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getMostPlayedHeroes } from "@/data/player-dto"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { toTitleCase } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -67,12 +68,14 @@ export default async function PlayerDashboardDemoPage( }, }); + const scoutingEnabled = await scoutingTool(); + return (
- +
From 357ceff404e3819762af6e84cd19146401f2f62f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:17:20 -0500 Subject: [PATCH 011/153] Add scouting models to Prisma schema and create corresponding migration files --- .../migration.sql | 94 +++++++++++++++++++ .../migration.sql | 6 ++ prisma/schema.prisma | 69 ++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 prisma/migrations/20260225024756_add_scouting_models/migration.sql create mode 100644 prisma/migrations/20260225025619_fix_scouting_match_nullable_scores/migration.sql diff --git a/prisma/migrations/20260225024756_add_scouting_models/migration.sql b/prisma/migrations/20260225024756_add_scouting_models/migration.sql new file mode 100644 index 000000000..e378edf3c --- /dev/null +++ b/prisma/migrations/20260225024756_add_scouting_models/migration.sql @@ -0,0 +1,94 @@ +-- CreateTable +CREATE TABLE "public"."ScoutingTournament" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "sourceUrl" TEXT NOT NULL, + "mapPool" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingTournament_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ScoutingMatch" ( + "id" SERIAL NOT NULL, + "tournamentId" INTEGER NOT NULL, + "team1" TEXT NOT NULL, + "team1FullName" TEXT NOT NULL, + "team2" TEXT NOT NULL, + "team2FullName" TEXT NOT NULL, + "team1Score" INTEGER NOT NULL, + "team2Score" INTEGER NOT NULL, + "bestOf" INTEGER NOT NULL, + "winner" TEXT NOT NULL, + "winnerFullName" TEXT NOT NULL, + "matchDate" TIMESTAMP(3) NOT NULL, + "mvp" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingMatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ScoutingMapResult" ( + "id" SERIAL NOT NULL, + "matchId" INTEGER NOT NULL, + "gameNumber" INTEGER NOT NULL, + "mapType" "public"."MapType" NOT NULL, + "mapName" TEXT NOT NULL, + "team1Score" TEXT NOT NULL, + "team2Score" TEXT NOT NULL, + "winner" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingMapResult_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ScoutingHeroBan" ( + "id" SERIAL NOT NULL, + "mapResultId" INTEGER NOT NULL, + "team" TEXT NOT NULL, + "hero" TEXT NOT NULL, + "banOrder" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingHeroBan_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScoutingTournament_title_key" ON "public"."ScoutingTournament"("title"); + +-- CreateIndex +CREATE INDEX "ScoutingMatch_tournamentId_idx" ON "public"."ScoutingMatch"("tournamentId"); + +-- CreateIndex +CREATE INDEX "ScoutingMatch_team1_team2_idx" ON "public"."ScoutingMatch"("team1", "team2"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScoutingMatch_tournamentId_team1_team2_matchDate_key" ON "public"."ScoutingMatch"("tournamentId", "team1", "team2", "matchDate"); + +-- CreateIndex +CREATE INDEX "ScoutingMapResult_matchId_idx" ON "public"."ScoutingMapResult"("matchId"); + +-- CreateIndex +CREATE INDEX "ScoutingMapResult_mapName_idx" ON "public"."ScoutingMapResult"("mapName"); + +-- CreateIndex +CREATE INDEX "ScoutingHeroBan_mapResultId_idx" ON "public"."ScoutingHeroBan"("mapResultId"); + +-- CreateIndex +CREATE INDEX "ScoutingHeroBan_hero_idx" ON "public"."ScoutingHeroBan"("hero"); + +-- AddForeignKey +ALTER TABLE "public"."ScoutingMatch" ADD CONSTRAINT "ScoutingMatch_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "public"."ScoutingTournament"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScoutingMapResult" ADD CONSTRAINT "ScoutingMapResult_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."ScoutingMatch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScoutingHeroBan" ADD CONSTRAINT "ScoutingHeroBan_mapResultId_fkey" FOREIGN KEY ("mapResultId") REFERENCES "public"."ScoutingMapResult"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260225025619_fix_scouting_match_nullable_scores/migration.sql b/prisma/migrations/20260225025619_fix_scouting_match_nullable_scores/migration.sql new file mode 100644 index 000000000..38d1a0e4d --- /dev/null +++ b/prisma/migrations/20260225025619_fix_scouting_match_nullable_scores/migration.sql @@ -0,0 +1,6 @@ +-- DropIndex +DROP INDEX "public"."ScoutingMatch_tournamentId_team1_team2_matchDate_key"; + +-- AlterTable +ALTER TABLE "public"."ScoutingMatch" ALTER COLUMN "team1Score" DROP NOT NULL, +ALTER COLUMN "team2Score" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ceeaff16..3cb6da602 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -897,3 +897,72 @@ model UltimateStart { @@index([scrimId]) @@index([MapDataId]) } + +// --------------------------------------------------------------------------- +// Scouting models — professional tournament data from Liquipedia +// --------------------------------------------------------------------------- + +model ScoutingTournament { + id Int @id @default(autoincrement()) + title String @unique + sourceUrl String + mapPool Json + matches ScoutingMatch[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ScoutingMatch { + id Int @id @default(autoincrement()) + tournamentId Int + team1 String + team1FullName String + team2 String + team2FullName String + team1Score Int? + team2Score Int? + bestOf Int + winner String + winnerFullName String + matchDate DateTime + mvp String? + maps ScoutingMapResult[] + tournament ScoutingTournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tournamentId]) + @@index([team1, team2]) +} + +model ScoutingMapResult { + id Int @id @default(autoincrement()) + matchId Int + gameNumber Int + mapType MapType + mapName String + team1Score String + team2Score String + winner String + heroBans ScoutingHeroBan[] + match ScoutingMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([matchId]) + @@index([mapName]) +} + +model ScoutingHeroBan { + id Int @id @default(autoincrement()) + mapResultId Int + team String + hero String + banOrder Int + mapResult ScoutingMapResult @relation(fields: [mapResultId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([mapResultId]) + @@index([hero]) +} From b284953f88fb315889fc29c9d98a383d7f268c16 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:17:25 -0500 Subject: [PATCH 012/153] Add script to seed scouting data from JSON file into the database, including tournament, match, map results, and hero bans --- scripts/seed-scouting-data.ts | 253 ++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 scripts/seed-scouting-data.ts diff --git a/scripts/seed-scouting-data.ts b/scripts/seed-scouting-data.ts new file mode 100644 index 000000000..93470a018 --- /dev/null +++ b/scripts/seed-scouting-data.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { type MapType } from "@prisma/client"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +interface HeroBanData { + hero: string; + order: number; +} + +interface MapResultData { + gameNumber: number; + mapType: string; + mapName: string; + team1Score: string; + team2Score: string; + winner: string; + team1HeroBan: HeroBanData | null; + team2HeroBan: HeroBanData | null; +} + +interface MatchData { + team1: string; + team1FullName: string; + team2: string; + team2FullName: string; + team1Score: number; + team2Score: number; + bestOf: number; + winner: string; + winnerFullName: string; + date: string; + timestamp: number; + maps: MapResultData[]; + mvp: string | null; +} + +interface TournamentData { + title: string; + sourceUrl: string; + mapPool: Record; + matches: MatchData[]; +} + +const VALID_MAP_TYPES: Set = new Set([ + "Clash", + "Control", + "Escort", + "Flashpoint", + "Hybrid", + "Push", +]); + +function toMapType(raw: string): MapType | null { + if (!VALID_MAP_TYPES.has(raw)) { + return null; + } + return raw as MapType; +} + +function collectHeroBans(map: MapResultData): Array<{ + team: string; + hero: string; + banOrder: number; +}> { + const bans: Array<{ team: string; hero: string; banOrder: number }> = []; + if (map.team1HeroBan) { + bans.push({ + team: "team1", + hero: map.team1HeroBan.hero, + banOrder: map.team1HeroBan.order, + }); + } + if (map.team2HeroBan) { + bans.push({ + team: "team2", + hero: map.team2HeroBan.hero, + banOrder: map.team2HeroBan.order, + }); + } + return bans; +} + +async function seedMatch(tournamentId: number, match: MatchData) { + const matchDate = new Date(match.timestamp * 1000); + let mapCount = 0; + let heroBanCount = 0; + + const createdMatch = await prisma.scoutingMatch.create({ + data: { + tournamentId, + team1: match.team1, + team1FullName: match.team1FullName, + team2: match.team2, + team2FullName: match.team2FullName, + team1Score: match.team1Score, + team2Score: match.team2Score, + bestOf: match.bestOf, + winner: match.winner, + winnerFullName: match.winnerFullName, + matchDate, + mvp: match.mvp, + }, + }); + + for (const map of match.maps) { + const mapType = toMapType(map.mapType); + if (!mapType) { + console.log(` Skipping map ${map.gameNumber} with unknown type "${map.mapType}"`); + continue; + } + + const heroBans = collectHeroBans(map); + + await prisma.scoutingMapResult.create({ + data: { + matchId: createdMatch.id, + gameNumber: map.gameNumber, + mapType, + mapName: map.mapName, + team1Score: map.team1Score, + team2Score: map.team2Score, + winner: map.winner, + heroBans: { + createMany: { data: heroBans }, + }, + }, + }); + mapCount++; + heroBanCount += heroBans.length; + } + + return { mapCount, heroBanCount }; +} + +async function seedTournament(tournament: TournamentData) { + const existing = await prisma.scoutingTournament.findUnique({ + where: { title: tournament.title }, + }); + + if (existing) { + console.log(` Skipping "${tournament.title}" (already exists)`); + return { matches: 0, maps: 0, heroBans: 0, skipped: true }; + } + + const createdTournament = await prisma.scoutingTournament.create({ + data: { + title: tournament.title, + sourceUrl: tournament.sourceUrl, + mapPool: tournament.mapPool, + }, + }); + + let matchCount = 0; + let mapCount = 0; + let heroBanCount = 0; + + try { + for (const match of tournament.matches) { + const result = await seedMatch(createdTournament.id, match); + matchCount++; + mapCount += result.mapCount; + heroBanCount += result.heroBanCount; + } + } catch (error) { + console.error(` Rolling back tournament "${tournament.title}" due to error`); + await prisma.scoutingTournament.delete({ + where: { id: createdTournament.id }, + }); + throw error; + } + + return { matches: matchCount, maps: mapCount, heroBans: heroBanCount, skipped: false }; +} + +async function main() { + const dataPath = resolve( + process.env.HOME ?? "~", + "code/scouting-scraper/data/json/all_tournaments.json" + ); + + console.log(`Reading data from ${dataPath}\n`); + + const raw = readFileSync(dataPath, "utf-8"); + const tournaments: TournamentData[] = JSON.parse(raw); + + console.log(`Found ${tournaments.length} tournaments to process\n`); + + let totalMatches = 0; + let totalMaps = 0; + let totalHeroBans = 0; + let inserted = 0; + let skipped = 0; + + for (const tournament of tournaments) { + console.log(`Processing "${tournament.title}"...`); + + try { + const result = await seedTournament(tournament); + + if (result.skipped) { + skipped++; + } else { + inserted++; + totalMatches += result.matches; + totalMaps += result.maps; + totalHeroBans += result.heroBans; + console.log( + ` Inserted ${result.matches} matches, ${result.maps} maps, ${result.heroBans} hero bans` + ); + } + } catch (error) { + console.error( + ` Error seeding "${tournament.title}":`, + error instanceof Error ? error.message : String(error) + ); + } + } + + console.log("\n" + "=".repeat(50)); + console.log("SEED SUMMARY"); + console.log("=".repeat(50)); + console.log(`Tournaments inserted: ${inserted}`); + console.log(`Tournaments skipped: ${skipped}`); + console.log(`Total matches: ${totalMatches}`); + console.log(`Total map results: ${totalMaps}`); + console.log(`Total hero bans: ${totalHeroBans}`); + console.log("=".repeat(50)); + + const dbCounts = await Promise.all([ + prisma.scoutingTournament.count(), + prisma.scoutingMatch.count(), + prisma.scoutingMapResult.count(), + prisma.scoutingHeroBan.count(), + ]); + + console.log("\nDatabase totals:"); + console.log(` ScoutingTournament: ${dbCounts[0]}`); + console.log(` ScoutingMatch: ${dbCounts[1]}`); + console.log(` ScoutingMapResult: ${dbCounts[2]}`); + console.log(` ScoutingHeroBan: ${dbCounts[3]}`); +} + +main() + .catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); From 8b1f7e236b13ede2ed675366432ed61c896b982c Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:24:11 -0500 Subject: [PATCH 013/153] Implement team name cleaning function to remove Liquipedia suffix in scouting data seeding --- scripts/seed-scouting-data.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/seed-scouting-data.ts b/scripts/seed-scouting-data.ts index 93470a018..928653ac1 100644 --- a/scripts/seed-scouting-data.ts +++ b/scripts/seed-scouting-data.ts @@ -53,6 +53,14 @@ const VALID_MAP_TYPES: Set = new Set([ "Push", ]); +const LIQUIPEDIA_SUFFIX = " (page does not exist)"; + +function cleanTeamName(name: string): string { + return name.endsWith(LIQUIPEDIA_SUFFIX) + ? name.slice(0, -LIQUIPEDIA_SUFFIX.length) + : name; +} + function toMapType(raw: string): MapType | null { if (!VALID_MAP_TYPES.has(raw)) { return null; @@ -92,14 +100,14 @@ async function seedMatch(tournamentId: number, match: MatchData) { data: { tournamentId, team1: match.team1, - team1FullName: match.team1FullName, + team1FullName: cleanTeamName(match.team1FullName), team2: match.team2, - team2FullName: match.team2FullName, + team2FullName: cleanTeamName(match.team2FullName), team1Score: match.team1Score, team2Score: match.team2Score, bestOf: match.bestOf, winner: match.winner, - winnerFullName: match.winnerFullName, + winnerFullName: cleanTeamName(match.winnerFullName), matchDate, mvp: match.mvp, }, From 7932120739413226e4de149eb29b57466e7b1306 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:32:48 -0500 Subject: [PATCH 014/153] Add TeamSearch component for scouting team search functionality --- src/components/scouting/team-search.tsx | 200 ++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/components/scouting/team-search.tsx diff --git a/src/components/scouting/team-search.tsx b/src/components/scouting/team-search.tsx new file mode 100644 index 000000000..6d09d02e6 --- /dev/null +++ b/src/components/scouting/team-search.tsx @@ -0,0 +1,200 @@ +"use client"; + +import type { ScoutingTeam } from "@/data/scouting-dto"; +import { cn } from "@/lib/utils"; +import Fuse from "fuse.js"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import type { Route } from "next"; +import { useCallback, useMemo, useRef, useState } from "react"; + +type TeamSearchProps = { + teams: ScoutingTeam[]; +}; + +export function TeamSearch({ teams }: TeamSearchProps) { + const t = useTranslations("scoutingPage.search"); + const router = useRouter(); + const inputRef = useRef(null); + const listRef = useRef(null); + + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const fuse = useMemo( + () => + new Fuse(teams, { + keys: ["abbreviation", "fullName"], + threshold: 0.3, + includeScore: true, + ignoreLocation: true, + }), + [teams] + ); + + const results = useMemo(() => { + if (!query.trim()) return []; + return fuse.search(query, { limit: 12 }).map((result) => result.item); + }, [query, fuse]); + + const navigateToTeam = useCallback( + (team: ScoutingTeam) => { + router.push( + `/scouting/team/${encodeURIComponent(team.abbreviation)}` as Route + ); + }, + [router] + ); + + function handleKeyDown(e: React.KeyboardEvent) { + if (results.length === 0) return; + + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + const nextIndex = activeIndex < results.length - 1 ? activeIndex + 1 : 0; + setActiveIndex(nextIndex); + scrollActiveIntoView(nextIndex); + break; + } + case "ArrowUp": { + e.preventDefault(); + const prevIndex = activeIndex > 0 ? activeIndex - 1 : results.length - 1; + setActiveIndex(prevIndex); + scrollActiveIntoView(prevIndex); + break; + } + case "Enter": { + e.preventDefault(); + if (results[activeIndex]) { + navigateToTeam(results[activeIndex]); + } + break; + } + case "Escape": { + e.preventDefault(); + setQuery(""); + setActiveIndex(0); + inputRef.current?.blur(); + break; + } + } + } + + function scrollActiveIntoView(index: number) { + const list = listRef.current; + if (!list) return; + const item = list.children[index] as HTMLElement | undefined; + item?.scrollIntoView({ block: "nearest" }); + } + + function formatWinRate(team: ScoutingTeam): string { + if (team.matchCount === 0) return "0%"; + return `${Math.round((team.winCount / team.matchCount) * 100)}%`; + } + + const showResults = query.trim().length > 0; + const activeDescendant = + results.length > 0 ? `team-result-${activeIndex}` : undefined; + + return ( +
+
+ +
+ + {showResults && ( +
    + {results.length > 0 ? ( + results.map((team, index) => ( +
  • setActiveIndex(index)} + onClick={() => navigateToTeam(team)} + > +
    + + {team.abbreviation} + + + {team.fullName} + +
    +
    + + {t("matchCount", { count: team.matchCount })} + + + {formatWinRate(team)} {t("winRate")} + +
    +
  • + )) + ) : ( +
  • + {t("noResults")} +
  • + )} +
+ )} + + {!showResults && ( +

+ {t("helperText")} +

+ )} +
+ ); +} From 5f87b8d766178615410fa424c6b4eefb31492814 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:32:59 -0500 Subject: [PATCH 015/153] Add getScoutingTeams function and ScoutingTeam type for retrieving and structuring scouting team data --- src/data/scouting-dto.ts | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/data/scouting-dto.ts diff --git a/src/data/scouting-dto.ts b/src/data/scouting-dto.ts new file mode 100644 index 000000000..6e8c310c4 --- /dev/null +++ b/src/data/scouting-dto.ts @@ -0,0 +1,49 @@ +import "server-only"; + +import prisma from "@/lib/prisma"; +import { cache } from "react"; + +export type ScoutingTeam = { + abbreviation: string; + fullName: string; + matchCount: number; + winCount: number; +}; + +type TeamAppearanceRow = { + team: string; + team_full_name: string; + match_count: bigint; + win_count: bigint; +}; + +async function getScoutingTeamsFn(): Promise { + const rows = await prisma.$queryRaw` + WITH appearances AS ( + SELECT team1 AS team, "team1FullName" AS team_full_name, id, + CASE WHEN winner = team1 THEN 1 ELSE 0 END AS won + FROM "ScoutingMatch" + UNION ALL + SELECT team2 AS team, "team2FullName" AS team_full_name, id, + CASE WHEN winner = team2 THEN 1 ELSE 0 END AS won + FROM "ScoutingMatch" + ) + SELECT + team, + team_full_name, + COUNT(*)::bigint AS match_count, + SUM(won)::bigint AS win_count + FROM appearances + GROUP BY team, team_full_name + ORDER BY match_count DESC + `; + + return rows.map((row) => ({ + abbreviation: row.team, + fullName: row.team_full_name, + matchCount: Number(row.match_count), + winCount: Number(row.win_count), + })); +} + +export const getScoutingTeams = cache(getScoutingTeamsFn); From cc30c8be23e772f14e0d4a376a9f3c6010d298da Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:33:19 -0500 Subject: [PATCH 016/153] Add Scouting page layout and functionality, including metadata and team search features --- messages/en.json | 20 ++++++++++++++++++++ src/app/scouting/layout.tsx | 36 ++++++++++++++++++++++++++++++++++++ src/app/scouting/page.tsx | 20 ++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/app/scouting/layout.tsx create mode 100644 src/app/scouting/page.tsx diff --git a/messages/en.json b/messages/en.json index 5e6a9adbf..76243247e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1469,6 +1469,26 @@ "bannedBy": "{hero} banned by {team}" } }, + "scoutingPage": { + "title": "Scouting", + "subtitle": "Search for OWCS teams and analyze their tournament performance.", + "metadata": { + "title": "Scouting | Parsertime", + "description": "Scout OWCS teams — search by name, view match history, win rates, and tournament performance.", + "ogTitle": "Scouting | Parsertime", + "ogDescription": "Scout OWCS teams — search by name, view match history, win rates, and tournament performance.", + "ogImage": "Scouting" + }, + "search": { + "label": "Search teams", + "placeholder": "Search for a team...", + "helperText": "Search for an OWCS team by name or abbreviation.", + "resultsLabel": "Team search results", + "matchCount": "{count, plural, one {# match} other {# matches}}", + "winRate": "WR", + "noResults": "No teams found." + } + }, "statsPage": { "layoutMetadata": { "title": "Stats | Parsertime", diff --git a/src/app/scouting/layout.tsx b/src/app/scouting/layout.tsx new file mode 100644 index 000000000..58b10b2ed --- /dev/null +++ b/src/app/scouting/layout.tsx @@ -0,0 +1,36 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata( + props: LayoutProps<"/scouting"> +): Promise { + const params = (await props.params) as { locale: string }; + const t = await getTranslations("scoutingPage.metadata"); + + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + url: "https://parsertime.app", + type: "website", + siteName: "Parsertime", + images: [ + { + url: `https://parsertime.app/api/og?title=${t("ogImage")}`, + width: 1200, + height: 630, + }, + ], + locale: params.locale, + }, + }; +} + +export default function ScoutingLayout({ + children, +}: LayoutProps<"/scouting">) { + return {children}; +} diff --git a/src/app/scouting/page.tsx b/src/app/scouting/page.tsx new file mode 100644 index 000000000..52c01a81e --- /dev/null +++ b/src/app/scouting/page.tsx @@ -0,0 +1,20 @@ +import { TeamSearch } from "@/components/scouting/team-search"; +import { getScoutingTeams } from "@/data/scouting-dto"; +import { getTranslations } from "next-intl/server"; + +export default async function ScoutingPage() { + const t = await getTranslations("scoutingPage"); + const teams = await getScoutingTeams(); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+
+ ); +} From 8ba39b151cc0d3057c6f3b8c15deb22e873f4168 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:52:07 -0500 Subject: [PATCH 017/153] Add detailed ScoutingTeamProfile type and related data processing functions for enhanced team performance analysis --- src/data/scouting-dto.ts | 287 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/src/data/scouting-dto.ts b/src/data/scouting-dto.ts index 6e8c310c4..317959885 100644 --- a/src/data/scouting-dto.ts +++ b/src/data/scouting-dto.ts @@ -1,6 +1,7 @@ import "server-only"; import prisma from "@/lib/prisma"; +import type { MapType } from "@prisma/client"; import { cache } from "react"; export type ScoutingTeam = { @@ -10,6 +11,91 @@ export type ScoutingTeam = { winCount: number; }; +export type MatchResult = "win" | "loss"; + +export type ScoutingTeamOverview = { + totalMatches: number; + wins: number; + losses: number; + winRate: number; + weightedWinRate: number; + recentForm: MatchResult[]; +}; + +export type HeroBanEntry = { + hero: string; + rawCount: number; + weightedCount: number; +}; + +export type ScoutingHeroBans = { + bansByTeam: HeroBanEntry[]; + bansAgainstTeam: HeroBanEntry[]; +}; + +export type MapPerformanceEntry = { + name: string; + played: number; + won: number; + winRate: number; + weightedWinRate: number; +}; + +export type ScoutingMapAnalysis = { + byMap: MapPerformanceEntry[]; + byMapType: (MapPerformanceEntry & { mapType: MapType })[]; +}; + +export type ScoutingMatchHistoryEntry = { + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + result: MatchResult; + tournament: string; +}; + +export type ScoutingRecommendation = { + name: string; + reason: string; + weightedWinRate: number; + sampleSize: number; +}; + +export type ScoutingRecommendations = { + suggestedBans: ScoutingRecommendation[]; + suggestedMapPicks: ScoutingRecommendation[]; + suggestedMapAvoids: ScoutingRecommendation[]; +}; + +export type ScoutingTeamProfile = { + team: { abbreviation: string; fullName: string }; + overview: ScoutingTeamOverview; + heroBans: ScoutingHeroBans; + mapAnalysis: ScoutingMapAnalysis; + matchHistory: ScoutingMatchHistoryEntry[]; + recommendations: ScoutingRecommendations; +}; + +const HALF_LIFE_DAYS = 90; +const DECAY_CONSTANT = Math.LN2 / HALF_LIFE_DAYS; + +function calculateWeight(matchDate: Date): number { + const daysAgo = (Date.now() - matchDate.getTime()) / (1000 * 60 * 60 * 24); + return Math.exp(-DECAY_CONSTANT * daysAgo); +} + +function weightedRate(items: { won: boolean; weight: number }[]): number { + if (items.length === 0) return 0; + const totalWeight = items.reduce((sum, i) => sum + i.weight, 0); + if (totalWeight === 0) return 0; + const winWeight = items + .filter((i) => i.won) + .reduce((sum, i) => sum + i.weight, 0); + return (winWeight / totalWeight) * 100; +} + type TeamAppearanceRow = { team: string; team_full_name: string; @@ -47,3 +133,204 @@ async function getScoutingTeamsFn(): Promise { } export const getScoutingTeams = cache(getScoutingTeamsFn); + +async function getScoutingTeamProfileFn( + teamAbbr: string +): Promise { + const matches = await prisma.scoutingMatch.findMany({ + where: { + OR: [{ team1: teamAbbr }, { team2: teamAbbr }], + }, + include: { + maps: { include: { heroBans: true } }, + tournament: { select: { title: true } }, + }, + orderBy: { matchDate: "desc" }, + }); + + if (matches.length === 0) return null; + + const firstMatch = matches[0]; + const fullName = + firstMatch.team1 === teamAbbr + ? firstMatch.team1FullName + : firstMatch.team2FullName; + + type ProcessedMatch = { + isTeam1: boolean; + won: boolean; + weight: number; + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + tournament: string; + maps: typeof firstMatch.maps; + }; + + const processed: ProcessedMatch[] = matches.map((m) => { + const isTeam1 = m.team1 === teamAbbr; + const won = m.winner === teamAbbr; + return { + isTeam1, + won, + weight: calculateWeight(m.matchDate), + date: m.matchDate, + opponent: isTeam1 ? m.team2 : m.team1, + opponentFullName: isTeam1 ? m.team2FullName : m.team1FullName, + teamScore: isTeam1 ? m.team1Score : m.team2Score, + opponentScore: isTeam1 ? m.team2Score : m.team1Score, + tournament: m.tournament.title, + maps: m.maps, + }; + }); + + const wins = processed.filter((m) => m.won).length; + const losses = processed.length - wins; + const overview: ScoutingTeamOverview = { + totalMatches: processed.length, + wins, + losses, + winRate: processed.length > 0 ? (wins / processed.length) * 100 : 0, + weightedWinRate: weightedRate(processed), + recentForm: processed.slice(0, 10).map((m) => (m.won ? "win" : "loss")), + }; + + const bansByTeamMap = new Map(); + const bansAgainstMap = new Map(); + + for (const match of processed) { + const teamSide = match.isTeam1 ? "team1" : "team2"; + + for (const map of match.maps) { + for (const ban of map.heroBans) { + const target = ban.team === teamSide ? bansByTeamMap : bansAgainstMap; + const existing = target.get(ban.hero) ?? { raw: 0, weighted: 0 }; + existing.raw += 1; + existing.weighted += match.weight; + target.set(ban.hero, existing); + } + } + } + + function toBanEntries( + map: Map + ): HeroBanEntry[] { + return Array.from(map.entries()) + .map(([hero, data]) => ({ + hero, + rawCount: data.raw, + weightedCount: Math.round(data.weighted * 100) / 100, + })) + .sort((a, b) => b.weightedCount - a.weightedCount); + } + + const heroBans: ScoutingHeroBans = { + bansByTeam: toBanEntries(bansByTeamMap), + bansAgainstTeam: toBanEntries(bansAgainstMap), + }; + + type MapAccum = { played: { won: boolean; weight: number }[] }; + const byMapName = new Map(); + const byMapTypeMap = new Map(); + + for (const match of processed) { + const teamSide = match.isTeam1 ? "team1" : "team2"; + for (const map of match.maps) { + const mapWon = map.winner === teamSide; + const entry = { won: mapWon, weight: match.weight }; + + const nameAccum = byMapName.get(map.mapName) ?? { played: [] }; + nameAccum.played.push(entry); + byMapName.set(map.mapName, nameAccum); + + const typeAccum = byMapTypeMap.get(map.mapType) ?? { played: [] }; + typeAccum.played.push(entry); + byMapTypeMap.set(map.mapType, typeAccum); + } + } + + function toMapPerformance( + accum: MapAccum + ): Pick< + MapPerformanceEntry, + "played" | "won" | "winRate" | "weightedWinRate" + > { + const won = accum.played.filter((p) => p.won).length; + return { + played: accum.played.length, + won, + winRate: accum.played.length > 0 ? (won / accum.played.length) * 100 : 0, + weightedWinRate: weightedRate(accum.played), + }; + } + + const mapAnalysis: ScoutingMapAnalysis = { + byMap: Array.from(byMapName.entries()) + .map(([name, accum]) => ({ name, ...toMapPerformance(accum) })) + .sort((a, b) => b.played - a.played), + byMapType: Array.from(byMapTypeMap.entries()) + .map(([mapType, accum]) => ({ + name: mapType, + mapType, + ...toMapPerformance(accum), + })) + .sort((a, b) => b.played - a.played), + }; + + const matchHistory: ScoutingMatchHistoryEntry[] = processed.map((m) => ({ + date: m.date, + opponent: m.opponent, + opponentFullName: m.opponentFullName, + teamScore: m.teamScore, + opponentScore: m.opponentScore, + result: m.won ? "win" : "loss", + tournament: m.tournament, + })); + + const suggestedBans: ScoutingRecommendation[] = heroBans.bansAgainstTeam + .slice(0, 5) + .map((ban) => ({ + name: ban.hero, + reason: `Banned against ${teamAbbr} ${ban.rawCount} times (opponents target this hero)`, + weightedWinRate: ban.weightedCount, + sampleSize: ban.rawCount, + })); + + const MIN_MAP_SAMPLE = 3; + const mapsWithEnoughData = mapAnalysis.byMap.filter( + (m) => m.played >= MIN_MAP_SAMPLE + ); + + const suggestedMapPicks: ScoutingRecommendation[] = [...mapsWithEnoughData] + .sort((a, b) => a.weightedWinRate - b.weightedWinRate) + .slice(0, 3) + .map((m) => ({ + name: m.name, + reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, + weightedWinRate: m.weightedWinRate, + sampleSize: m.played, + })); + + const suggestedMapAvoids: ScoutingRecommendation[] = [...mapsWithEnoughData] + .sort((a, b) => b.weightedWinRate - a.weightedWinRate) + .slice(0, 3) + .map((m) => ({ + name: m.name, + reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, + weightedWinRate: m.weightedWinRate, + sampleSize: m.played, + })); + + return { + team: { abbreviation: teamAbbr, fullName }, + overview, + heroBans, + mapAnalysis, + matchHistory, + recommendations: { suggestedBans, suggestedMapPicks, suggestedMapAvoids }, + }; +} + +export const getScoutingTeamProfile = cache(getScoutingTeamProfileFn); From 7ecc74d78e86fcb9ad3b01ffbe95092812bd202a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:52:44 -0500 Subject: [PATCH 018/153] Add HeroBanChart component to visualize hero bans data with bar charts for team performance analysis --- src/components/scouting/hero-ban-chart.tsx | 127 +++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/components/scouting/hero-ban-chart.tsx diff --git a/src/components/scouting/hero-ban-chart.tsx b/src/components/scouting/hero-ban-chart.tsx new file mode 100644 index 000000000..c167779e5 --- /dev/null +++ b/src/components/scouting/hero-ban-chart.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { ChartConfig } from "@/components/ui/chart"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ScoutingHeroBans } from "@/data/scouting-dto"; +import { useTranslations } from "next-intl"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +type HeroBanChartProps = { + heroBans: ScoutingHeroBans; +}; + +const MAX_BARS = 10; + +export function HeroBanChart({ heroBans }: HeroBanChartProps) { + const t = useTranslations("scoutingPage.team.heroBans"); + + const bansAgainstData = heroBans.bansAgainstTeam + .slice(0, MAX_BARS) + .map((b) => ({ hero: b.hero, count: b.rawCount, weighted: b.weightedCount })); + + const bansByData = heroBans.bansByTeam + .slice(0, MAX_BARS) + .map((b) => ({ hero: b.hero, count: b.rawCount, weighted: b.weightedCount })); + + const bansAgainstConfig: ChartConfig = { + weighted: { + label: t("weighted"), + color: "var(--chart-1)", + }, + }; + + const bansByConfig: ChartConfig = { + weighted: { + label: t("weighted"), + color: "var(--chart-2)", + }, + }; + + return ( +
+ + + {t("bansAgainstTeam")} + {t("bansAgainstDescription")} + + + {bansAgainstData.length > 0 ? ( + + ) : ( +

+ {t("noBans")} +

+ )} +
+
+ + + + {t("bansByTeam")} + {t("bansByDescription")} + + + {bansByData.length > 0 ? ( + + ) : ( +

+ {t("noBans")} +

+ )} +
+
+
+ ); +} + +type BanBarChartProps = { + data: { hero: string; count: number; weighted: number }[]; + config: ChartConfig; +}; + +function BanBarChart({ data, config }: BanBarChartProps) { + const chartHeight = Math.max(200, data.length * 36); + + return ( + + + + + + } /> + + + + ); +} From bbe380e35d33bcd41a577f6c71e2c0802fe51d9d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:52:49 -0500 Subject: [PATCH 019/153] Add MapPerformanceChart component to visualize map performance data with bar charts and detailed map statistics for scouting analysis --- .../scouting/map-performance-chart.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/components/scouting/map-performance-chart.tsx diff --git a/src/components/scouting/map-performance-chart.tsx b/src/components/scouting/map-performance-chart.tsx new file mode 100644 index 000000000..48d0ad259 --- /dev/null +++ b/src/components/scouting/map-performance-chart.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { ChartConfig } from "@/components/ui/chart"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ScoutingMapAnalysis } from "@/data/scouting-dto"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "next-intl"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +type MapPerformanceChartProps = { + mapAnalysis: ScoutingMapAnalysis; +}; + +export function MapPerformanceChart({ mapAnalysis }: MapPerformanceChartProps) { + const t = useTranslations("scoutingPage.team.maps"); + + const mapTypeData = mapAnalysis.byMapType.map((entry) => ({ + mode: entry.mapType, + winRate: Math.round(entry.weightedWinRate * 10) / 10, + played: entry.played, + })); + + const chartConfig: ChartConfig = { + winRate: { + label: t("weightedWinRate"), + color: "var(--chart-1)", + }, + }; + + return ( +
+ + + {t("byMapType")} + {t("byMapTypeDescription")} + + + {mapTypeData.length > 0 ? ( + + + + + `${v}%`} + /> + `${Number(value)}%`} + /> + } + /> + + + + ) : ( +

+ {t("noMaps")} +

+ )} +
+
+ + + + {t("byMap")} + {t("byMapDescription")} + + + {mapAnalysis.byMap.length > 0 ? ( +
+ {mapAnalysis.byMap.map((map) => ( + + ))} +
+ ) : ( +

+ {t("noMaps")} +

+ )} +
+
+
+ ); +} + +type MapCardProps = { + map: ScoutingMapAnalysis["byMap"][number]; +}; + +function MapCard({ map }: MapCardProps) { + const t = useTranslations("scoutingPage.team.maps"); + const wr = map.weightedWinRate; + + return ( +
= 60 + ? "border-emerald-500/30 bg-emerald-500/5" + : wr <= 40 + ? "border-red-500/30 bg-red-500/5" + : "border-border" + )} + > +
+ {map.name} + = 50 ? "default" : "destructive"} + className="tabular-nums" + > + {wr.toFixed(0)}% + +
+
+ + {t("played")}: {map.played} + + + {t("won")}: {map.won} + +
+
+ ); +} From d0402070cd5340e7d4dabad61400ffa60003c216 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:52:54 -0500 Subject: [PATCH 020/153] Add MatchHistoryTable component to display and paginate match history data for scouting analysis --- .../scouting/match-history-table.tsx | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/components/scouting/match-history-table.tsx diff --git a/src/components/scouting/match-history-table.tsx b/src/components/scouting/match-history-table.tsx new file mode 100644 index 000000000..f39c05409 --- /dev/null +++ b/src/components/scouting/match-history-table.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { ScoutingMatchHistoryEntry } from "@/data/scouting-dto"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type MatchHistoryTableProps = { + matches: ScoutingMatchHistoryEntry[]; +}; + +const PAGE_SIZE = 10; + +export function MatchHistoryTable({ matches }: MatchHistoryTableProps) { + const t = useTranslations("scoutingPage.team.overview"); + const [page, setPage] = useState(0); + + const totalPages = Math.max(1, Math.ceil(matches.length / PAGE_SIZE)); + const pageMatches = matches.slice( + page * PAGE_SIZE, + (page + 1) * PAGE_SIZE + ); + + function formatDate(date: Date) { + return new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + return ( + + + {t("matchHistory")} + {t("matchHistoryDescription")} + + + {matches.length > 0 ? ( + + + + {t("date")} + {t("opponent")} + {t("score")} + {t("result")} + + {t("tournament")} + + + + + {pageMatches.map((match) => ( + + + {formatDate(match.date)} + + + {match.opponent} + + {match.opponentFullName} + + + + {match.teamScore ?? "?"} –{" "} + {match.opponentScore ?? "?"} + + + + {match.result === "win" ? t("win") : t("loss")} + + + + {match.tournament} + + + ))} + +
+ ) : ( +

+ {t("noMatches")} +

+ )} +
+ {totalPages > 1 && ( + + + + {t("pageInfo", { current: page + 1, total: totalPages })} + + + + )} +
+ ); +} From d6541f3fa9cfce74561520b87b5d188832c1a4a2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:52:59 -0500 Subject: [PATCH 021/153] Add ScoutingRecommendations component to display team recommendations for bans, map picks, and map avoids with confidence levels --- .../scouting/scouting-recommendations.tsx | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/components/scouting/scouting-recommendations.tsx diff --git a/src/components/scouting/scouting-recommendations.tsx b/src/components/scouting/scouting-recommendations.tsx new file mode 100644 index 000000000..10725f5f1 --- /dev/null +++ b/src/components/scouting/scouting-recommendations.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { + ScoutingRecommendation, + ScoutingRecommendations as ScoutingRecommendationsType, +} from "@/data/scouting-dto"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "next-intl"; + +type ScoutingRecommendationsProps = { + recommendations: ScoutingRecommendationsType; +}; + +export function ScoutingRecommendations({ + recommendations, +}: ScoutingRecommendationsProps) { + const t = useTranslations("scoutingPage.team.recommendations"); + + const hasAny = + recommendations.suggestedBans.length > 0 || + recommendations.suggestedMapPicks.length > 0 || + recommendations.suggestedMapAvoids.length > 0; + + if (!hasAny) { + return ( + + + {t("title")} + {t("description")} + + +

+ {t("noRecommendations")} +

+
+
+ ); + } + + return ( +
+
+

{t("title")}

+

{t("description")}

+
+ +
+ + + +
+
+ ); +} + +type RecommendationCardProps = { + title: string; + description: string; + items: ScoutingRecommendation[]; + variant: "ban" | "pick" | "avoid"; +}; + +function RecommendationCard({ + title, + description, + items, + variant, +}: RecommendationCardProps) { + const t = useTranslations("scoutingPage.team.recommendations"); + + return ( + + + {title} + {description} + + + {items.length > 0 ? ( +
    + {items.map((item, i) => ( +
  1. + + {i + 1} + +
    +
    + {item.name} + +
    +

    + {item.reason} +

    + {variant !== "ban" && ( + + {item.weightedWinRate.toFixed(0)}% {t("weightedWinRate")} + + )} +
    +
  2. + ))} +
+ ) : ( +

+ {t("noRecommendations")} +

+ )} +
+
+ ); +} + +function ConfidenceBadge({ sampleSize }: { sampleSize: number }) { + const t = useTranslations("scoutingPage.team.recommendations"); + + const level = + sampleSize >= 10 ? "high" : sampleSize >= 5 ? "medium" : "low"; + + return ( + + {t(`confidence.${level}`)} + + ); +} From 6700d905be100fc13e2085a0a111f99019649945 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:53:03 -0500 Subject: [PATCH 022/153] Add TeamOverviewCards component to display team performance metrics, including wins, losses, win rates, and recent match form in a card layout for scouting analysis --- .../scouting/team-overview-cards.tsx | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/components/scouting/team-overview-cards.tsx diff --git a/src/components/scouting/team-overview-cards.tsx b/src/components/scouting/team-overview-cards.tsx new file mode 100644 index 000000000..c243e7577 --- /dev/null +++ b/src/components/scouting/team-overview-cards.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { MatchResult, ScoutingTeamOverview } from "@/data/scouting-dto"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "next-intl"; + +type TeamOverviewCardsProps = { + overview: ScoutingTeamOverview; +}; + +export function TeamOverviewCards({ overview }: TeamOverviewCardsProps) { + const t = useTranslations("scoutingPage.team.overview"); + + return ( +
+
+ + + {t("record")} + + +

+ {overview.wins}W – {overview.losses}L +

+

+ {overview.totalMatches} {t("matchCount", { count: overview.totalMatches })} +

+
+
+ + + + {t("winRate")} + + +

+ {overview.winRate.toFixed(1)}% +

+

{t("allTime")}

+
+
+ + + + {t("weightedWinRate")} + {t("weightedWinRateTooltip")} + + +

+ {overview.weightedWinRate.toFixed(1)}% +

+
+
+
+ + + + {t("recentForm")} + {t("recentFormDescription")} + + + {overview.recentForm.length > 0 ? ( +
+ {recentFormWithKeys(overview.recentForm).map(({ key, result }) => ( + + {result === "win" ? "W" : "L"} + + ))} +
+ ) : ( +

{t("noMatches")}

+ )} +
+
+
+ ); +} + +function recentFormWithKeys(form: MatchResult[]) { + const winCounts = new Map(); + return form.map((result) => { + const count = (winCounts.get(result) ?? 0) + 1; + winCounts.set(result, count); + return { key: `${result}-${count}`, result }; + }); +} From ce07a88b90e2fdcb80ffc82446c7c23721c49f69 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:53:29 -0500 Subject: [PATCH 023/153] Add team scouting page with detailed metadata, overview, hero bans, map performance, and strategic recommendations for enhanced team analysis --- messages/en.json | 80 +++++++++++++ src/app/scouting/team/[teamAbbr]/layout.tsx | 38 +++++++ src/app/scouting/team/[teamAbbr]/page.tsx | 117 ++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/app/scouting/team/[teamAbbr]/layout.tsx create mode 100644 src/app/scouting/team/[teamAbbr]/page.tsx diff --git a/messages/en.json b/messages/en.json index 76243247e..7321cbd86 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1487,6 +1487,86 @@ "matchCount": "{count, plural, one {# match} other {# matches}}", "winRate": "WR", "noResults": "No teams found." + }, + "team": { + "metadata": { + "title": "{team} Scouting | Parsertime", + "description": "Scout {team} — view match history, hero bans, map performance, and strategic recommendations.", + "ogTitle": "{team} Scouting | Parsertime", + "ogDescription": "Scout {team} — view match history, hero bans, map performance, and strategic recommendations." + }, + "backToSearch": "Back to search", + "tabs": { + "overview": "Overview", + "heroBans": "Hero Bans", + "maps": "Maps", + "recommendations": "Recommendations" + }, + "overview": { + "record": "Record", + "winRate": "Win Rate", + "weightedWinRate": "Weighted WR", + "weightedWinRateTooltip": "Win rate weighted by recency — recent matches count more than older ones.", + "recentForm": "Recent Form", + "recentFormDescription": "Last 10 match results", + "matchHistory": "Match History", + "matchHistoryDescription": "All recorded matches, most recent first", + "date": "Date", + "opponent": "Opponent", + "score": "Score", + "result": "Result", + "tournament": "Tournament", + "win": "W", + "loss": "L", + "matchCount": "{count, plural, one {match} other {matches}}", + "allTime": "All-time win rate", + "noMatches": "No matches found for this team.", + "previousPage": "Previous", + "nextPage": "Next", + "pageInfo": "Page {current} of {total}" + }, + "heroBans": { + "bansAgainstTeam": "Bans Against Them", + "bansAgainstDescription": "Heroes opponents ban when facing this team — reveals perceived strengths.", + "bansByTeam": "Their Bans", + "bansByDescription": "Heroes this team bans — reveals what they want to deny opponents.", + "hero": "Hero", + "count": "Count", + "weighted": "Weighted", + "noBans": "No hero ban data available." + }, + "maps": { + "byMapType": "Win Rate by Map Type", + "byMapTypeDescription": "Performance breakdown by game mode", + "byMap": "Per-Map Performance", + "byMapDescription": "Win rate on individual maps", + "mapType": "Mode", + "map": "Map", + "played": "Played", + "won": "Won", + "winRate": "Win Rate", + "weightedWinRate": "Weighted WR", + "noMaps": "No map data available." + }, + "recommendations": { + "title": "Strategic Recommendations", + "description": "Actionable insights based on recency-weighted analysis of this team's performance.", + "heroesToBan": "Heroes to Ban", + "heroesToBanDescription": "Heroes opponents most frequently ban against this team — these are their perceived strengths.", + "mapsToPick": "Maps to Pick", + "mapsToPickDescription": "Maps where this team has the lowest weighted win rate — exploit these weaknesses.", + "mapsToAvoid": "Maps to Avoid", + "mapsToAvoidDescription": "Maps where this team has the highest weighted win rate — stay away from their strengths.", + "weightedWinRate": "weighted WR", + "sampleSize": "{count, plural, one {# game} other {# games}}", + "noRecommendations": "Not enough data to generate recommendations.", + "confidence": { + "high": "High confidence", + "medium": "Medium confidence", + "low": "Low confidence" + } + }, + "empty": "No scouting data available for this team." } }, "statsPage": { diff --git a/src/app/scouting/team/[teamAbbr]/layout.tsx b/src/app/scouting/team/[teamAbbr]/layout.tsx new file mode 100644 index 000000000..647fc47dc --- /dev/null +++ b/src/app/scouting/team/[teamAbbr]/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { getLocale, getTranslations } from "next-intl/server"; + +export async function generateMetadata( + props: LayoutProps<"/scouting/team/[teamAbbr]"> +): Promise { + const params = await props.params; + const t = await getTranslations("scoutingPage.team.metadata"); + const locale = await getLocale(); + + const team = decodeURIComponent(params.teamAbbr); + + return { + title: t("title", { team }), + description: t("description", { team }), + openGraph: { + title: t("ogTitle", { team }), + description: t("ogDescription", { team }), + url: "https://parsertime.app", + type: "website", + siteName: "Parsertime", + images: [ + { + url: `https://parsertime.app/api/og?title=${encodeURIComponent(t("ogTitle", { team }))}`, + width: 1200, + height: 630, + }, + ], + locale, + }, + }; +} + +export default function ScoutingTeamLayout({ + children, +}: LayoutProps<"/scouting/team/[teamAbbr]">) { + return children; +} diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx new file mode 100644 index 000000000..587ba3e3e --- /dev/null +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -0,0 +1,117 @@ +import { HeroBanChart } from "@/components/scouting/hero-ban-chart"; +import { MapPerformanceChart } from "@/components/scouting/map-performance-chart"; +import { MatchHistoryTable } from "@/components/scouting/match-history-table"; +import { ScoutingRecommendations } from "@/components/scouting/scouting-recommendations"; +import { TeamOverviewCards } from "@/components/scouting/team-overview-cards"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getScoutingTeamProfile } from "@/data/scouting-dto"; +import { ArrowLeft } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export default async function ScoutingTeamPage( + props: PageProps<"/scouting/team/[teamAbbr]"> +) { + const params = await props.params; + const teamAbbr = decodeURIComponent(params.teamAbbr); + const t = await getTranslations("scoutingPage.team"); + + const profile = await getScoutingTeamProfile(teamAbbr); + if (!profile) notFound(); + + const { overview } = profile; + + return ( +
+
+ +
+ + + + {t("tabs.overview")} + {t("tabs.heroBans")} + {t("tabs.maps")} + + {t("tabs.recommendations")} + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function FormStreak({ form }: { form: ("win" | "loss")[] }) { + if (form.length === 0) return null; + + let streak = 1; + const currentResult = form[0]; + for (let i = 1; i < form.length; i++) { + if (form[i] === currentResult) streak++; + else break; + } + + const label = currentResult === "win" ? "W" : "L"; + + return ( + + {label} + {streak} + + ); +} From c2cd38d43dd07b24a3bb8f8dae6d50906f01fdcc Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:59:11 -0500 Subject: [PATCH 024/153] Add MethodologyCard component to display methodology points with translations for enhanced scouting insights --- src/components/scouting/methodology-card.tsx | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/components/scouting/methodology-card.tsx diff --git a/src/components/scouting/methodology-card.tsx b/src/components/scouting/methodology-card.tsx new file mode 100644 index 000000000..e80985168 --- /dev/null +++ b/src/components/scouting/methodology-card.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Info } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type MethodologyCardProps = { + translationKey: string; +}; + +export function MethodologyCard({ translationKey }: MethodologyCardProps) { + const t = useTranslations(translationKey); + + const points = getPoints(t); + + return ( + + + + + + +
    + {points.map((point) => ( +
  • +
  • + ))} +
+
+
+ ); +} + +function getPoints(t: ReturnType): string[] { + const points: string[] = []; + for (let i = 0; t.has(`points.${i}`); i++) { + points.push(t(`points.${i}`)); + } + return points; +} From 63c85bcc560f6a9532b96822702469e05146ac07 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:59:18 -0500 Subject: [PATCH 025/153] Add methodology details to scouting page components for enhanced insights on stats, hero bans, map performance, and recommendations --- messages/en.json | 43 +++++++++++++++++++++-- src/app/scouting/team/[teamAbbr]/page.tsx | 5 +++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/messages/en.json b/messages/en.json index 7321cbd86..6a0f2b23c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1523,7 +1523,16 @@ "noMatches": "No matches found for this team.", "previousPage": "Previous", "nextPage": "Next", - "pageInfo": "Page {current} of {total}" + "pageInfo": "Page {current} of {total}", + "methodology": { + "title": "How we calculate these stats", + "points": { + "0": "Win rate is calculated from all recorded OWCS matches for this team.", + "1": "Weighted win rate uses exponential decay with a 90-day half-life — a match from 90 days ago counts half as much as one from today.", + "2": "Recent form shows the last 10 match results in chronological order, most recent first.", + "3": "Match data is sourced from Liquipedia and covers the last year of OWCS tournaments." + } + } }, "heroBans": { "bansAgainstTeam": "Bans Against Them", @@ -1533,7 +1542,16 @@ "hero": "Hero", "count": "Count", "weighted": "Weighted", - "noBans": "No hero ban data available." + "noBans": "No hero ban data available.", + "methodology": { + "title": "How we analyze hero bans", + "points": { + "0": "Each OWCS map may include one hero ban per team. We track which heroes are banned by and against this team across all maps.", + "1": "Weighted counts apply exponential decay (90-day half-life) so recent ban patterns are prioritized over older ones.", + "2": "Bans Against Them reveals what opponents perceive as this team's strengths — the heroes they don't want to face.", + "3": "Their Bans reveals what this team considers threatening — the heroes they choose to deny opponents." + } + } }, "maps": { "byMapType": "Win Rate by Map Type", @@ -1546,7 +1564,16 @@ "won": "Won", "winRate": "Win Rate", "weightedWinRate": "Weighted WR", - "noMaps": "No map data available." + "noMaps": "No map data available.", + "methodology": { + "title": "How we measure map performance", + "points": { + "0": "Win rate per map and map type is computed from individual map results within each series, not just series wins.", + "1": "Weighted win rate applies exponential decay (90-day half-life) — recent map results matter more than older ones.", + "2": "Maps highlighted in green have a weighted win rate above 60%. Maps in red are below 40%.", + "3": "Map types aggregate all maps of that mode (e.g., all Control maps) into a single win rate." + } + } }, "recommendations": { "title": "Strategic Recommendations", @@ -1564,6 +1591,16 @@ "high": "High confidence", "medium": "Medium confidence", "low": "Low confidence" + }, + "methodology": { + "title": "How we generate recommendations", + "points": { + "0": "Heroes to Ban are the heroes most frequently banned against this team by their opponents — banning these denies their perceived strengths.", + "1": "Maps to Pick are maps where this team has the lowest recency-weighted win rate (minimum 3 games played). Picking these exploits their weaknesses.", + "2": "Maps to Avoid are maps where this team has the highest recency-weighted win rate. Avoid giving them home-field advantage.", + "3": "Confidence levels are based on sample size: High (10+ games), Medium (5-9 games), Low (under 5 games).", + "4": "All metrics use a 90-day half-life exponential decay, so recommendations reflect the current meta rather than outdated results." + } } }, "empty": "No scouting data available for this team." diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index 587ba3e3e..da3d090ce 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -1,6 +1,7 @@ import { HeroBanChart } from "@/components/scouting/hero-ban-chart"; import { MapPerformanceChart } from "@/components/scouting/map-performance-chart"; import { MatchHistoryTable } from "@/components/scouting/match-history-table"; +import { MethodologyCard } from "@/components/scouting/methodology-card"; import { ScoutingRecommendations } from "@/components/scouting/scouting-recommendations"; import { TeamOverviewCards } from "@/components/scouting/team-overview-cards"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -70,20 +71,24 @@ export default async function ScoutingTeamPage( + + + +
From b0e03eb1f5d13ca023bb9cb93882625ae46158e8 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 22:59:27 -0500 Subject: [PATCH 026/153] Update navigation links in MainNav component to correctly reflect scouting for teams and players --- src/components/dashboard/main-nav.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/dashboard/main-nav.tsx b/src/components/dashboard/main-nav.tsx index 5f938ab81..5708cce9c 100644 --- a/src/components/dashboard/main-nav.tsx +++ b/src/components/dashboard/main-nav.tsx @@ -164,20 +164,20 @@ export function MainNav({ : "text-muted-foreground" )} > - {t("scoutPlayer")} + {t("scoutTeam")} - {t("scoutTeam")} + {t("scoutPlayer")} From 5285699d8663d5fb33f7ca0b9dfdd4e541ecae1e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Tue, 24 Feb 2026 23:02:03 -0500 Subject: [PATCH 027/153] Implement scouting feature toggle in ScoutingPage and ScoutingTeamPage components to handle unavailable scouting functionality --- src/app/scouting/page.tsx | 5 +++++ src/app/scouting/team/[teamAbbr]/page.tsx | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app/scouting/page.tsx b/src/app/scouting/page.tsx index 52c01a81e..653c35410 100644 --- a/src/app/scouting/page.tsx +++ b/src/app/scouting/page.tsx @@ -1,8 +1,13 @@ import { TeamSearch } from "@/components/scouting/team-search"; import { getScoutingTeams } from "@/data/scouting-dto"; +import { scoutingTool } from "@/lib/flags"; import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; export default async function ScoutingPage() { + const scoutingEnabled = await scoutingTool(); + if (!scoutingEnabled) notFound(); + const t = await getTranslations("scoutingPage"); const teams = await getScoutingTeams(); diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index da3d090ce..73270c294 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -6,6 +6,7 @@ import { ScoutingRecommendations } from "@/components/scouting/scouting-recommen import { TeamOverviewCards } from "@/components/scouting/team-overview-cards"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getScoutingTeamProfile } from "@/data/scouting-dto"; +import { scoutingTool } from "@/lib/flags"; import { ArrowLeft } from "lucide-react"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; @@ -14,6 +15,9 @@ import { notFound } from "next/navigation"; export default async function ScoutingTeamPage( props: PageProps<"/scouting/team/[teamAbbr]"> ) { + const scoutingEnabled = await scoutingTool(); + if (!scoutingEnabled) notFound(); + const params = await props.params; const teamAbbr = decodeURIComponent(params.teamAbbr); const t = await getTranslations("scoutingPage.team"); @@ -51,7 +55,8 @@ export default async function ScoutingTeamPage( {overview.winRate.toFixed(1)}% {t("overview.winRate")} - {overview.weightedWinRate.toFixed(1)}% {t("overview.weightedWinRate")} + {overview.weightedWinRate.toFixed(1)}%{" "} + {t("overview.weightedWinRate")}
@@ -85,9 +90,7 @@ export default async function ScoutingTeamPage( - + From 097f368b19c4eb3b082bd157d5faa829c092bbb3 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 00:16:30 -0500 Subject: [PATCH 028/153] Add matchRoomUrl and vods fields to ScoutingMatch model in schema.prisma and create migration for database update --- .../20260225051611_add_vods_and_match_room_url/migration.sql | 3 +++ prisma/schema.prisma | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 prisma/migrations/20260225051611_add_vods_and_match_room_url/migration.sql diff --git a/prisma/migrations/20260225051611_add_vods_and_match_room_url/migration.sql b/prisma/migrations/20260225051611_add_vods_and_match_room_url/migration.sql new file mode 100644 index 000000000..cd020a84e --- /dev/null +++ b/prisma/migrations/20260225051611_add_vods_and_match_room_url/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."ScoutingMatch" ADD COLUMN "matchRoomUrl" TEXT, +ADD COLUMN "vods" JSONB NOT NULL DEFAULT '[]'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3cb6da602..9dae84615 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -926,6 +926,8 @@ model ScoutingMatch { winnerFullName String matchDate DateTime mvp String? + vods Json @default("[]") + matchRoomUrl String? maps ScoutingMapResult[] tournament ScoutingTournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) From 3980261f74f42d83a983243e2362e075ebbd8786 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 00:20:42 -0500 Subject: [PATCH 029/153] Add VodData interface and update MatchData structure to include vods and matchRoomUrl fields for enhanced match details --- scripts/seed-scouting-data.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/seed-scouting-data.ts b/scripts/seed-scouting-data.ts index 928653ac1..882b54fc4 100644 --- a/scripts/seed-scouting-data.ts +++ b/scripts/seed-scouting-data.ts @@ -21,6 +21,11 @@ interface MapResultData { team2HeroBan: HeroBanData | null; } +interface VodData { + url: string; + platform: string; +} + interface MatchData { team1: string; team1FullName: string; @@ -35,6 +40,8 @@ interface MatchData { timestamp: number; maps: MapResultData[]; mvp: string | null; + vods: VodData[]; + matchRoomUrl: string | null; } interface TournamentData { @@ -110,6 +117,8 @@ async function seedMatch(tournamentId: number, match: MatchData) { winnerFullName: cleanTeamName(match.winnerFullName), matchDate, mvp: match.mvp, + vods: match.vods ?? [], + matchRoomUrl: match.matchRoomUrl ?? null, }, }); From 880ff3cce6fac807cbc589a4ee5ff66af25dfe34 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 00:35:28 -0500 Subject: [PATCH 030/153] Add team composition fields to ScoutingMapResult model in schema.prisma and create migration for database update --- .../migration.sql | 3 +++ prisma/schema.prisma | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 prisma/migrations/20260225053446_add_team_comp_to_scouting_map_result/migration.sql diff --git a/prisma/migrations/20260225053446_add_team_comp_to_scouting_map_result/migration.sql b/prisma/migrations/20260225053446_add_team_comp_to_scouting_map_result/migration.sql new file mode 100644 index 000000000..6ac9731d8 --- /dev/null +++ b/prisma/migrations/20260225053446_add_team_comp_to_scouting_map_result/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."ScoutingMapResult" ADD COLUMN "team1Comp" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "team2Comp" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9dae84615..8217db27d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -946,6 +946,8 @@ model ScoutingMapResult { team1Score String team2Score String winner String + team1Comp String[] @default([]) + team2Comp String[] @default([]) heroBans ScoutingHeroBan[] match ScoutingMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) From a8a6ce8441a9c25af5c468509ece6daebb4865a6 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 03:18:42 -0500 Subject: [PATCH 031/153] Add API route for saving team compositions with validation and error handling --- src/app/api/data-labeling/save-comp/route.ts | 163 +++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/app/api/data-labeling/save-comp/route.ts diff --git a/src/app/api/data-labeling/save-comp/route.ts b/src/app/api/data-labeling/save-comp/route.ts new file mode 100644 index 000000000..b8e1ca29a --- /dev/null +++ b/src/app/api/data-labeling/save-comp/route.ts @@ -0,0 +1,163 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const heroNames = Object.keys(heroRoleMapping) as [string, ...string[]]; + +const CompSchema = z.object({ + mapResultId: z.number().int().positive(), + team1Comp: z.array(z.enum(heroNames)).length(5), + team2Comp: z.array(z.enum(heroNames)).length(5), +}); + +function validateRoleConstraint(heroes: string[]): boolean { + let tanks = 0; + let damage = 0; + let support = 0; + + for (const hero of heroes) { + const role = heroRoleMapping[hero as HeroName]; + if (role === "Tank") tanks++; + else if (role === "Damage") damage++; + else if (role === "Support") support++; + } + + return tanks === 1 && damage === 2 && support === 2; +} + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/data-labeling/save-comp", + timestamp: new Date().toISOString(), + }; + + try { + const enabled = await dataLabeling(); + if (!enabled) { + wideEvent.status_code = 404; + wideEvent.outcome = "feature_disabled"; + return new Response("Not found", { status: 404 }); + } + + 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 = CompSchema.safeParse(await request.json()); + if (!body.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "validation_failed"; + wideEvent.error = { message: body.error.message }; + return NextResponse.json( + { success: false, error: "Invalid request body" }, + { status: 400 } + ); + } + + const { mapResultId, team1Comp, team2Comp } = body.data; + + wideEvent.request_params = { + map_result_id: mapResultId, + team1_comp: team1Comp, + team2_comp: team2Comp, + }; + + if (!validateRoleConstraint(team1Comp)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team1_roles"; + wideEvent.error = { + message: "Team 1 must have exactly 1 Tank, 2 Damage, 2 Support", + }; + return NextResponse.json( + { + success: false, + error: "Team 1 must have exactly 1 Tank, 2 Damage, 2 Support", + }, + { status: 400 } + ); + } + + if (!validateRoleConstraint(team2Comp)) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_team2_roles"; + wideEvent.error = { + message: "Team 2 must have exactly 1 Tank, 2 Damage, 2 Support", + }; + return NextResponse.json( + { + success: false, + error: "Team 2 must have exactly 1 Tank, 2 Damage, 2 Support", + }, + { status: 400 } + ); + } + + const mapResult = await prisma.scoutingMapResult.findUnique({ + where: { id: mapResultId }, + select: { id: true, matchId: true }, + }); + + if (!mapResult) { + wideEvent.status_code = 404; + wideEvent.outcome = "map_result_not_found"; + wideEvent.error = { message: "Map result not found" }; + return NextResponse.json( + { success: false, error: "Map result not found" }, + { status: 404 } + ); + } + + await prisma.scoutingMapResult.update({ + where: { id: mapResultId }, + data: { team1Comp, team2Comp }, + }); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + map_result_id: mapResultId, + match_id: mapResult.matchId, + team1_hero_count: team1Comp.length, + team2_hero_count: team2Comp.length, + }; + + return NextResponse.json({ success: true }); + } 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 saving team composition", error); + return NextResponse.json( + { success: false, error: "Failed to save team composition" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} From f4e40a751540780369d60d7501d273427cd733cc Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 03:19:21 -0500 Subject: [PATCH 032/153] Add Data Labeling feature with layout and page components, including translations and match listing functionality --- messages/en.json | 45 +++++++++++++++++++++++++++++++- src/app/data-labeling/layout.tsx | 20 ++++++++++++++ src/app/data-labeling/page.tsx | 25 ++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/app/data-labeling/layout.tsx create mode 100644 src/app/data-labeling/page.tsx diff --git a/messages/en.json b/messages/en.json index 6a0f2b23c..a4688f099 100644 --- a/messages/en.json +++ b/messages/en.json @@ -381,7 +381,8 @@ "teamStats": "Team Stats", "scouting": "Scouting", "scoutPlayer": "Scout a player", - "scoutTeam": "Scout a team" + "scoutTeam": "Scout a team", + "dataLabeling": "Data Labeling" }, "teamSwitcher": { "searchTeamPlaceholder": "Search team...", @@ -1606,6 +1607,48 @@ "empty": "No scouting data available for this team." } }, + "dataLabeling": { + "title": "Data Labeling", + "subtitle": "Label team compositions for OWCS tournament matches.", + "metadata": { + "title": "Data Labeling | Parsertime", + "description": "Label team compositions for OWCS tournament matches." + }, + "matchList": { + "date": "Date", + "teams": "Teams", + "score": "Score", + "tournament": "Tournament", + "progress": "Progress", + "previousPage": "Previous", + "nextPage": "Next", + "pageInfo": "Page {current} of {total}", + "noMatches": "No unlabeled matches with VODs found.", + "vs": "vs" + }, + "labeling": { + "backToList": "Back to matches", + "map": "Map {number}", + "mapTabs": "Maps", + "labeled": "Labeled", + "unlabeled": "Unlabeled", + "heroBans": "Hero Bans", + "noBans": "No bans for this map.", + "team1Comp": "{team} Composition", + "team2Comp": "{team} Composition", + "tank": "Tank", + "damage": "Damage", + "support": "Support", + "save": "Save", + "saveAll": "Save All Maps", + "saving": "Saving...", + "saved": "Saved!", + "saveSuccess": "Team compositions saved successfully.", + "saveError": "Failed to save team compositions.", + "roleConstraint": "Select 1 Tank, 2 Damage, 2 Support", + "heroBanned": "Banned" + } + }, "statsPage": { "layoutMetadata": { "title": "Stats | Parsertime", diff --git a/src/app/data-labeling/layout.tsx b/src/app/data-labeling/layout.tsx new file mode 100644 index 000000000..96520670f --- /dev/null +++ b/src/app/data-labeling/layout.tsx @@ -0,0 +1,20 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("dataLabeling.metadata"); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default function DataLabelingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/data-labeling/page.tsx b/src/app/data-labeling/page.tsx new file mode 100644 index 000000000..85047d5ac --- /dev/null +++ b/src/app/data-labeling/page.tsx @@ -0,0 +1,25 @@ +import { UnlabeledMatchList } from "@/components/data-labeling/unlabeled-match-list"; +import { getUnlabeledMatches } from "@/data/data-labeling-dto"; +import { dataLabeling } from "@/lib/flags"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export default async function DataLabelingPage() { + const enabled = await dataLabeling(); + if (!enabled) notFound(); + + const t = await getTranslations("dataLabeling"); + const result = await getUnlabeledMatches(0, 20); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+
+ ); +} From 732be1107cac107c1707a6c94e338fd0eb0cec45 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 03:19:40 -0500 Subject: [PATCH 033/153] Add Match Labeling page component to handle match data labeling --- .../data-labeling/match/[matchId]/page.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/app/data-labeling/match/[matchId]/page.tsx diff --git a/src/app/data-labeling/match/[matchId]/page.tsx b/src/app/data-labeling/match/[matchId]/page.tsx new file mode 100644 index 000000000..48bb3bde9 --- /dev/null +++ b/src/app/data-labeling/match/[matchId]/page.tsx @@ -0,0 +1,30 @@ +import { MatchLabelingView } from "@/components/data-labeling/match-labeling-view"; +import { getMatchForLabeling } from "@/data/data-labeling-dto"; +import { dataLabeling } from "@/lib/flags"; +import { notFound } from "next/navigation"; + +type Params = { matchId: string }; + +export default async function MatchLabelingPage({ + params, +}: { + params: Promise; +}) { + const enabled = await dataLabeling(); + if (!enabled) notFound(); + + const { matchId } = await params; + const id = Number(matchId); + if (Number.isNaN(id)) notFound(); + + const match = await getMatchForLabeling(id); + if (!match) notFound(); + + return ( +
+
+ +
+
+ ); +} From 8ac31d5b8d949c8595942c047488beb39808f375 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 03:20:10 -0500 Subject: [PATCH 034/153] Add HeroCompPicker component for team composition selection with role limits and hero banning functionality --- .../data-labeling/hero-comp-picker.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/components/data-labeling/hero-comp-picker.tsx diff --git a/src/components/data-labeling/hero-comp-picker.tsx b/src/components/data-labeling/hero-comp-picker.tsx new file mode 100644 index 000000000..932042ec9 --- /dev/null +++ b/src/components/data-labeling/hero-comp-picker.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { cn, toHero } from "@/lib/utils"; +import { + heroRoleMapping, + roleHeroMapping, + type HeroName, +} from "@/types/heroes"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import { useCallback } from "react"; + +type HeroCompPickerProps = { + teamLabel: string; + selectedHeroes: string[]; + onSelectionChange: (heroes: string[]) => void; + bannedHeroes: string[]; +}; + +const ROLE_LIMITS: Record = { + Tank: 1, + Damage: 2, + Support: 2, +}; + +const ROLE_ORDER: ("Tank" | "Damage" | "Support")[] = [ + "Tank", + "Damage", + "Support", +]; + +function countByRole(heroes: string[]): Record { + const counts: Record = { Tank: 0, Damage: 0, Support: 0 }; + for (const hero of heroes) { + const role = heroRoleMapping[hero as HeroName]; + if (role) counts[role]++; + } + return counts; +} + +export function HeroCompPicker({ + teamLabel, + selectedHeroes, + onSelectionChange, + bannedHeroes, +}: HeroCompPickerProps) { + const t = useTranslations("dataLabeling.labeling"); + const roleCounts = countByRole(selectedHeroes); + + const toggleHero = useCallback( + (hero: string) => { + if (bannedHeroes.includes(hero)) return; + + if (selectedHeroes.includes(hero)) { + onSelectionChange(selectedHeroes.filter((h) => h !== hero)); + return; + } + + const role = heroRoleMapping[hero as HeroName]; + if (!role) return; + + const currentCount = countByRole(selectedHeroes)[role]; + if (currentCount >= ROLE_LIMITS[role]) return; + + onSelectionChange([...selectedHeroes, hero]); + }, + [selectedHeroes, onSelectionChange, bannedHeroes] + ); + + const slots = [ + { role: "Tank" as const, index: 0 }, + { role: "Damage" as const, index: 0 }, + { role: "Damage" as const, index: 1 }, + { role: "Support" as const, index: 0 }, + { role: "Support" as const, index: 1 }, + ]; + + function getSlotHero(role: "Tank" | "Damage" | "Support", index: number) { + const heroesInRole = selectedHeroes.filter( + (h) => heroRoleMapping[h as HeroName] === role + ); + return heroesInRole[index]; + } + + return ( +
+

+ {t("team1Comp", { team: teamLabel })} +

+ +
+ {slots.map(({ role, index }) => { + const hero = getSlotHero(role, index); + return ( +
+ {hero ? ( + {hero} + ) : ( + + {t(role.toLowerCase() as "tank" | "damage" | "support")} + + )} +
+ ); + })} + + {selectedHeroes.length}/5 + +
+ +
+ {ROLE_ORDER.map((role) => { + const heroes = roleHeroMapping[role]; + const isFull = roleCounts[role] >= ROLE_LIMITS[role]; + + return ( +
+
+ + {t(role.toLowerCase() as "tank" | "damage" | "support")} + + + ({roleCounts[role]}/{ROLE_LIMITS[role]}) + +
+
+ {heroes.map((hero) => { + const isBanned = bannedHeroes.includes(hero); + const isSelected = selectedHeroes.includes(hero); + const isDisabled = isBanned || (isFull && !isSelected); + + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} From 4974ff4931341f5765b823ad84497a6422d03331 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Wed, 25 Feb 2026 03:20:27 -0500 Subject: [PATCH 035/153] Add MatchLabelingView component to display match details, team compositions, and VODs with validation and saving functionality --- .../data-labeling/match-labeling-view.tsx | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 src/components/data-labeling/match-labeling-view.tsx diff --git a/src/components/data-labeling/match-labeling-view.tsx b/src/components/data-labeling/match-labeling-view.tsx new file mode 100644 index 000000000..a0856d6f7 --- /dev/null +++ b/src/components/data-labeling/match-labeling-view.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { HeroCompPicker } from "@/components/data-labeling/hero-comp-picker"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { MatchForLabeling, MatchMapForLabeling } from "@/data/data-labeling-dto"; +import { toHero } from "@/lib/utils"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import { YouTubeEmbed } from "@next/third-parties/google"; +import { ArrowLeft, Check, Loader2 } from "lucide-react"; +import type { Route } from "next"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +type MatchLabelingViewProps = { + match: MatchForLabeling; +}; + +type MapCompState = { + team1Comp: string[]; + team2Comp: string[]; + dirty: boolean; + saving: boolean; + saved: boolean; +}; + +function validateRoleConstraint(heroes: string[]): boolean { + if (heroes.length !== 5) return false; + let tanks = 0; + let damage = 0; + let support = 0; + for (const hero of heroes) { + const role = heroRoleMapping[hero as HeroName]; + if (role === "Tank") tanks++; + else if (role === "Damage") damage++; + else if (role === "Support") support++; + } + return tanks === 1 && damage === 2 && support === 2; +} + +function extractYouTubeId(url: string): string { + if (url.startsWith("https://youtu.be/")) + return url.split("youtu.be/")[1].split("?")[0]; + if (url.includes("/embed/")) + return url.split("/embed/")[1].split("?")[0]; + if (url.includes("/live/")) + return url.split("/live/")[1].split("?")[0]; + return url.split("v=")[1]?.split("&")[0] || ""; +} + +function extractStartTime(url: string): number { + const match = url.match(/[?&]t=(\d+)/); + return match ? Number(match[1]) : 0; +} + +function getVodSource(url: string) { + if ( + url.startsWith("https://www.youtube.com/") || + url.startsWith("https://youtu.be/") || + url.startsWith("https://youtube.com/") + ) + return "youtube"; + if (url.startsWith("https://www.twitch.tv/videos/")) return "twitch"; + return null; +} + +export function MatchLabelingView({ match }: MatchLabelingViewProps) { + const t = useTranslations("dataLabeling.labeling"); + const [activeMap, setActiveMap] = useState( + match.maps[0]?.id.toString() ?? "" + ); + + const [mapStates, setMapStates] = useState>( + () => { + const states: Record = {}; + for (const map of match.maps) { + states[map.id] = { + team1Comp: map.team1Comp, + team2Comp: map.team2Comp, + dirty: false, + saving: false, + saved: map.team1Comp.length > 0 && map.team2Comp.length > 0, + }; + } + return states; + } + ); + + const updateMapComp = useCallback( + (mapId: number, team: "team1Comp" | "team2Comp", heroes: string[]) => { + setMapStates((prev) => ({ + ...prev, + [mapId]: { + ...prev[mapId], + [team]: heroes, + dirty: true, + saved: false, + }, + })); + }, + [] + ); + + const saveMapComp = useCallback( + async (mapId: number) => { + const state = mapStates[mapId]; + if (!state) return; + + if ( + !validateRoleConstraint(state.team1Comp) || + !validateRoleConstraint(state.team2Comp) + ) { + toast.error(t("roleConstraint")); + return; + } + + setMapStates((prev) => ({ + ...prev, + [mapId]: { ...prev[mapId], saving: true }, + })); + + try { + const res = await fetch("/api/data-labeling/save-comp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mapResultId: mapId, + team1Comp: state.team1Comp, + team2Comp: state.team2Comp, + }), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? "Save failed"); + } + + setMapStates((prev) => ({ + ...prev, + [mapId]: { ...prev[mapId], saving: false, dirty: false, saved: true }, + })); + toast.success(t("saveSuccess")); + } catch (err) { + setMapStates((prev) => ({ + ...prev, + [mapId]: { ...prev[mapId], saving: false }, + })); + toast.error( + err instanceof Error ? err.message : t("saveError") + ); + } + }, + [mapStates, t] + ); + + const vod = match.vods[0]; + const vodSource = vod ? getVodSource(vod.url) : null; + const parentDomain = process.env.NEXT_PUBLIC_VERCEL_URL + ? process.env.NEXT_PUBLIC_VERCEL_URL.replace(/^https?:\/\//, "").split( + "/" + )[0] + : "localhost"; + + return ( +
+
+ + + +
+

+ {match.team1FullName} vs {match.team2FullName} +

+ + {match.team1Score ?? "?"} – {match.team2Score ?? "?"} + +
+
+ +
+
+ {vod && vodSource === "youtube" && ( + + +
+ +
+
+
+ )} + + {vod && vodSource === "twitch" && ( + + +
+