diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml index ba33505b9..f4b22b245 100644 --- a/.github/workflows/vitest.yml +++ b/.github/workflows/vitest.yml @@ -5,9 +5,6 @@ name: Vitest on: [push] -env: - TEST_DB_URL: ${{ secrets.TEST_DB_URL }} - jobs: test: runs-on: ubuntu-latest diff --git a/messages/en.json b/messages/en.json index 4dc4977f0..7404140f0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -378,7 +378,11 @@ "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", + "dataLabeling": "Data Labeling" }, "teamSwitcher": { "searchTeamPlaceholder": "Search team...", @@ -470,7 +474,8 @@ "team": "Team: ", "noTeam": "Uncategorized", "creator": "Creator: ", - "noCreator": "Unknown" + "noCreator": "Unknown", + "viewScoutingReport": "View OWCS scouting report" }, "scrimCreationForm": { "fileSize": { @@ -1466,6 +1471,370 @@ "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." + }, + "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", + "players": "Roster Prep", + "report": "Report", + "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}", + "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. The sparkline shows the win/loss trajectory over those matches.", + "3": "Strength Rating is an Elo-style score centered at 1500. Beating higher-rated opponents raises it more than beating lower-rated ones. Below 5 matches the rating is marked as provisional.", + "4": "Record Quality counts how many wins came against teams rated above 1500 (the starting average), showing whether a strong record was earned against strong competition.", + "5": "Match data is sourced from Liquipedia and covers the last year of OWCS tournaments. When you select your team with the picker, your scrim data is also factored in." + } + } + }, + "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.", + "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": "Disruption Score measures how much banning a hero hurts the opponent. It combines the win-rate delta (their WR when the hero is available minus when it's banned) with sample reliability. A score above 10 is significant; above 20 is a high-impact ban target.", + "3": "Win-Rate Delta (pp) is in percentage points. For example, '−22pp when banned' means the opponent's win rate drops 22 percentage points when that hero is unavailable. Deltas above ±15pp are notable; above ±25pp are extreme.", + "4": "Their Ban Targets cross-references the opponent's ban history with your most-played heroes. Critical: they ban it 30%+ of maps and your team plays it 10%+ of the time. Watch: ban rate 15%+ and play rate 5%+. Safe: low overlap.", + "5": "Protected Heroes are heroes this team bans in fewer than 5% of their maps — these are composition staples they want 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.", + "methodology": { + "title": "How we measure map performance and matchups", + "points": { + "0": "Win rate per map is computed from individual map results within each series, not just series wins.", + "1": "Strength-weighted win rate adjusts for opponent quality using Elo ratings. A 60% WR against top-rated teams is worth more than 80% against bottom-rated teams. When no Elo data exists, raw WR is shown instead.", + "2": "Net advantage (pp) is the percentage-point gap between your map WR and the opponent's strength-weighted WR. Scale: ±10pp is a minor edge, ±20pp is a clear advantage, ±30pp+ is a dominant matchup. Only matchups with ≥15pp gap appear in the veto recommendation.", + "3": "Trend arrows compare the last 10 maps to the overall record for each map. A delta above +10pp is 'improving' (↑, shown in red — caution), below −10pp is 'declining' (↓, shown in green — opportunity), otherwise 'stable' (→).", + "4": "Confidence dots indicate sample size: filled green = 20+ maps (high), amber = 10–19 (medium), faded = 5–9 (low). Matchups where either side has fewer than 5 maps are hidden from the veto recommendation.", + "5": "When you select your team via the picker, your scrim map data is combined with the opponent's OWCS data. Both are decay-weighted identically; scrim data uses stricter confidence thresholds (30/15/7 maps instead of 20/10/5)." + } + } + }, + "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", + "insufficient": "Insufficient data" + }, + "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." + } + } + }, + "players": { + "methodology": { + "title": "How roster readiness is assessed", + "points": { + "0": "This tab shows YOUR roster's readiness against this specific opponent. It identifies which of your players are most at risk based on their hero depth and how this opponent's ban patterns target them.", + "1": "Z-scores measure how a player's per-10-minute stats on each hero compare to all hero performances in the same role across your roster. 0σ = role average, +1.5σ+ = standout, −0.5σ = below average.", + "2": "Stats weighted by role — Tank: deaths (30% inverted), elims (20%), damage (20%). Damage: elims (30%), damage (30%), deaths (20% inverted). Support: healing (35%), deaths (25% inverted), damage (14%), elims (10%).", + "3": "Primary/secondary delta is the z-score gap between a player's best and second-best hero. A delta above 1.5σ means a significant drop-off when forced off their main — they become a ban target for this opponent.", + "4": "Vulnerability Index = hero depth delta × opponent ban rate. Scale: above 0.5 = critical (large drop-off + frequently banned), 0.25–0.5 = high, 0.1–0.25 = moderate, below 0.1 = low.", + "5": "Heroes need at least 3 maps with 2+ minutes of play time to be included. Players need at least 5 total maps for a meaningful confidence rating." + } + } + }, + "report": { + "methodology": { + "title": "How the report is generated", + "points": { + "0": "Insights are generated by cross-referencing your team's uploaded scrim data with the opponent's competitive OWCS match history. Selecting your team with the picker enables the full cross-reference.", + "1": "Map matchup insights surface when the net advantage between your WR and the opponent's strength-weighted WR exceeds ±15pp. Scale: 15–25pp is a clear edge, 25–40pp is a major mismatch, 40pp+ is dominant.", + "2": "Ban disruption insights flag heroes where banning causes the opponent's win rate to drop significantly. A disruption score above 10 is notable; above 20 is a high-priority ban.", + "3": "Player vulnerability insights flag players whose primary hero is both significantly better than their secondary (>1.5σ delta) and frequently banned by the opponent (>15% ban rate).", + "4": "Each insight has a priority score (1–100) combining magnitude of advantage, confidence level, and actionability. Only the top 5 are featured; the rest appear in 'Additional Findings.'", + "5": "Confidence tiers — High: 20+ maps. Medium: 10–19. Low: 5–9. Insufficient (hidden): under 5. Scrim-only data uses stricter thresholds (30/15/7) since practice matches carry lower signal strength.", + "6": "Data source is shown on each insight card: 'Competitive' = OWCS matches only, 'Scrim' = your uploaded scrim data only, 'Competitive + Scrim' = both sources combined." + } + } + }, + "empty": "No scouting data available for this team." + }, + "player": { + "title": "Scout Player", + "subtitle": "Search for professional players and analyze their competitive history.", + "metadata": { + "title": "Scout Player | Parsertime", + "description": "Search for professional Overwatch players and view their competitive history, hero pool, and tournament performance.", + "ogTitle": "Scout Player | Parsertime", + "ogDescription": "Search for professional Overwatch players and view their competitive history, hero pool, and tournament performance.", + "ogImage": "Scout Player" + }, + "search": { + "label": "Search players", + "placeholder": "Search for a player...", + "helperText": "Search for a professional player by name, team, or role.", + "resultsLabel": "Player search results", + "noResults": "No players found." + }, + "profile": { + "metadata": { + "title": "{player} | Scout Player | Parsertime", + "description": "Scout {player} — view hero pool, tournament history, and competitive performance.", + "ogTitle": "{player} | Scout Player | Parsertime", + "ogDescription": "Scout {player} — view hero pool, tournament history, and competitive performance." + }, + "backToSearch": "Back to search", + "liquipediaProfile": "View on Liquipedia", + "careerWinnings": "Career Winnings", + "signatureHeroes": "Signature Heroes", + "tournamentAppearances": "Tournament Appearances", + "competitiveMaps": "Competitive Maps", + "tournaments": "{count, plural, one {# tournament} other {# tournaments}}", + "maps": "{count, plural, one {# map} other {# maps}}", + "heroPool": "Hero Pool", + "knownFor": "Known For", + "knownForSource": "From Liquipedia", + "observedInCompetition": "Observed In Competition", + "heroMaps": "{count, plural, one {# map} other {# maps}}", + "noHeroData": "No competitive hero data available yet.", + "tournamentHistory": "Tournament History", + "team": "Team", + "role": "Role", + "record": "Record", + "winRate": "Win Rate", + "noTournamentData": "No tournament data available yet.", + "matchDetails": "Match Details", + "opponent": "Opponent", + "score": "Score", + "result": "Result", + "heroes": "Heroes", + "win": "W", + "loss": "L" + }, + "analytics": { + "scrimOverview": { + "title": "Scrim Performance", + "description": "Key metrics from Parsertime scrim data.", + "mvpScore": "MVP Score", + "kdRatio": "K/D Ratio", + "firstPickRate": "First Pick Rate", + "firstDeathRate": "First Death Rate", + "fightReversalRate": "Fight Reversal %", + "killsPerUltimate": "Kills / Ult", + "consistencyScore": "Consistency", + "mapsPlayed": "Maps Played", + "firstPicks": "{count} first picks", + "firstDeaths": "{count} first deaths", + "outOf100": "/ 100", + "methodology": { + "mvpScore": "Points-based score comparing per-10 stats against global hero averages. Each stat contributes up to ±10 points weighted by importance (4 pts per standard deviation). Range is typically −100 to +100.", + "kdRatio": "Total eliminations divided by total deaths across all recorded scrims. Eliminations include both final blows and assists.", + "firstPickRate": "Percentage of team fights where this player scored the opening kill. Measured per-map and averaged across all maps.", + "firstDeathRate": "Percentage of team fights where this player was the first to die. A high rate may indicate aggressive positioning or being targeted.", + "fightReversalRate": "Percentage of fights won by this player's team after losing 2 or more players first. Indicates clutch potential.", + "killsPerUltimate": "Average number of eliminations achieved per ultimate ability used. Higher values suggest efficient ultimate usage.", + "consistencyScore": "Scored 0–100 based on how stable per-10 stats are across maps. Calculated as the inverse of the average coefficient of variation for eliminations, deaths, damage, and healing. Above 75 is highly consistent.", + "mapsPlayed": "Total number of scrim maps recorded in Parsertime for this player. Serves as the sample size for all scrim-based metrics." + } + }, + "performanceRadar": { + "title": "Performance Profile", + "description": "Z-scores vs global hero averages across all Parsertime players.", + "aboveAverage": "Above Average", + "belowAverage": "Below Average", + "noData": "No scrim data available for radar chart.", + "methodology": "Each axis shows how many standard deviations (σ) this player's per-10 stat is from the global average for their hero. Calculated using baselines from all Parsertime players on the same hero, across at least 3 maps of data." + }, + "heroZScores": { + "title": "Hero Performance", + "description": "Composite z-scores per hero compared to global baselines.", + "primary": "Primary", + "maps": "{count, plural, one {# map} other {# maps}}", + "delta": "Delta", + "significantDropOff": "Significant drop-off", + "noData": "No hero performance data available.", + "methodology": "Composite z-score is the weighted average of per-stat z-scores (eliminations, deaths, damage, healing, blocked) for each hero. Individual stat z-scores compare this player's per-10 rate to the global mean and standard deviation for that hero. The delta shows the gap between primary and secondary hero proficiency." + }, + "mapWinrates": { + "title": "Map Performance", + "description": "Win rates from competitive matches.", + "byMapType": "By Map Type", + "byMap": "By Map", + "competitive": "Competitive", + "scrims": "Scrims", + "played": "Played", + "won": "Won", + "noData": "No competitive map data available.", + "methodologyMapType": "Win rate by map type is calculated from all competitive match results where this player's team participated. Percentages are wins ÷ total maps played for each game mode (Control, Escort, Hybrid, Push, Flashpoint).", + "methodologyByMap": "Individual map win rates are calculated from competitive match results. Borders are color-coded: green for ≥60% win rate, red for ≤40%, and neutral otherwise." + }, + "killAnalysis": { + "title": "Combat Patterns", + "description": "Kill and death analysis from scrim data.", + "topTargets": "Top Targets", + "topThreats": "Top Threats", + "killMethods": "Kill Methods", + "roleDistribution": "Role Distribution", + "kills": "{count, plural, one {# kill} other {# kills}}", + "deaths": "{count, plural, one {# death} other {# deaths}}", + "methodology": "Kill and death data is sourced from Parsertime scrim logs. Top targets and threats show the 5 most-eliminated and most-died-to heroes by total count. Kill methods are grouped by ability type. Role distribution reflects the percentage of playtime on Tank, Damage, and Support based on hero time across all scrims." + }, + "strengthsWeaknesses": { + "title": "Scouting Insights", + "description": "Auto-generated strengths and weaknesses from competitive and scrim data.", + "strengths": "Strengths", + "weaknesses": "Weaknesses", + "noStrengths": "Not enough data to identify strengths.", + "noWeaknesses": "No significant weaknesses identified.", + "methodology": "Insights are auto-generated by analyzing z-scores (hero performance vs global averages), competitive map win rates, first pick/death rates, consistency scores, and hero pool depth. Strengths trigger when metrics exceed positive thresholds; weaknesses trigger on negative thresholds. All data sources are combined to provide a holistic view." + }, + "noScrimData": "This player doesn't have scrim data in Parsertime yet. Competitive analytics and tournament history are still available.", + "statLabels": { + "eliminations": "Eliminations", + "deaths": "Deaths", + "hero_damage_dealt": "Hero Damage", + "healing_dealt": "Healing", + "damage_blocked": "Damage Blocked" + } + } + } + }, + "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": { + "title": "Unlabeled Matches", + "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", + "reset": "Reset", + "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", + "playerAssignments": "{team} Player Assignments", + "assignPlayer": "Assign player for {hero}", + "selectPlayer": "Select player...", + "instructions": { + "title": "Reviewer Instructions", + "body": "Watch the VOD and use the panel on the right to record the hero composition for each map. For each hero, assign the player who was playing that hero at the start of the map.", + "swapNote": "Only capture the initial picks — mid-map hero swaps do not need to be recorded, as we are interested in each team's opening composition rather than matchup-driven substitutions." + } + } + }, "statsPage": { "layoutMetadata": { "title": "Stats | Parsertime", @@ -2029,6 +2398,13 @@ "title": "Read-Only", "description": "If enabled, the team will be read-only. This means that players and managers cannot add scrims to the team." }, + "scoutingLink": { + "title": "OWCS Scouting Link", + "description": "Link this team to an OWCS team abbreviation to enable cross-referenced scouting reports. This connects your uploaded scrim data to opponent match history.", + "placeholder": "e.g. CORNELL, NYU, COLU", + "noTeam": "No team linked", + "linked": "Linked to {abbr}" + }, "update": { "title": "Update team profile", "onSubmit": { diff --git a/messages/ko.json b/messages/ko.json index d93ce74fb..d1009b9df 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -424,7 +424,8 @@ "team": "팀: ", "noTeam": "분류되지 않음", "creator": "생성자: ", - "noCreator": "알 수 없음" + "noCreator": "알 수 없음", + "viewScoutingReport": "OWCS 스카우팅 보고서 보기" }, "scrimCreationForm": { "fileSize": { diff --git a/messages/zh.json b/messages/zh.json index f78846ebd..5d361280c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -424,7 +424,8 @@ "team": "团队: ", "noTeam": "未分类", "creator": "创建者: ", - "noCreator": "未知" + "noCreator": "未知", + "viewScoutingReport": "查看OWCS侦察报告" }, "scrimCreationForm": { "fileSize": { 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/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/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/migrations/20260225153111_add_scouting_rosters/migration.sql b/prisma/migrations/20260225153111_add_scouting_rosters/migration.sql new file mode 100644 index 000000000..82a4c7fd3 --- /dev/null +++ b/prisma/migrations/20260225153111_add_scouting_rosters/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "public"."RosterCategory" AS ENUM ('player', 'staff', 'substitute'); + +-- CreateEnum +CREATE TYPE "public"."RosterRole" AS ENUM ('DPS', 'Tank', 'Support', 'Flex', 'Coach', 'HeadCoach', 'AssistantCoach', 'Manager', 'AssistantManager', 'TeamManager', 'Analyst', 'PerformanceCoach', 'Substitute'); + +-- CreateTable +CREATE TABLE "public"."ScoutingRoster" ( + "id" SERIAL NOT NULL, + "tournamentId" INTEGER NOT NULL, + "teamName" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingRoster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ScoutingRosterPlayer" ( + "id" SERIAL NOT NULL, + "rosterId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "role" "public"."RosterRole" NOT NULL, + "country" TEXT NOT NULL, + "didNotPlay" BOOLEAN NOT NULL DEFAULT false, + "category" "public"."RosterCategory" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingRosterPlayer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ScoutingRoster_tournamentId_idx" ON "public"."ScoutingRoster"("tournamentId"); + +-- CreateIndex +CREATE INDEX "ScoutingRoster_teamName_idx" ON "public"."ScoutingRoster"("teamName"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScoutingRoster_tournamentId_teamName_key" ON "public"."ScoutingRoster"("tournamentId", "teamName"); + +-- CreateIndex +CREATE INDEX "ScoutingRosterPlayer_rosterId_idx" ON "public"."ScoutingRosterPlayer"("rosterId"); + +-- CreateIndex +CREATE INDEX "ScoutingRosterPlayer_displayName_idx" ON "public"."ScoutingRosterPlayer"("displayName"); + +-- AddForeignKey +ALTER TABLE "public"."ScoutingRoster" ADD CONSTRAINT "ScoutingRoster_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "public"."ScoutingTournament"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScoutingRosterPlayer" ADD CONSTRAINT "ScoutingRosterPlayer_rosterId_fkey" FOREIGN KEY ("rosterId") REFERENCES "public"."ScoutingRoster"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260225163750_add_scouting_hero_assignments/migration.sql b/prisma/migrations/20260225163750_add_scouting_hero_assignments/migration.sql new file mode 100644 index 000000000..7af498541 --- /dev/null +++ b/prisma/migrations/20260225163750_add_scouting_hero_assignments/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "public"."ScoutingHeroAssignment" ( + "id" SERIAL NOT NULL, + "mapResultId" INTEGER NOT NULL, + "team" TEXT NOT NULL, + "heroName" TEXT NOT NULL, + "playerName" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingHeroAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ScoutingHeroAssignment_mapResultId_idx" ON "public"."ScoutingHeroAssignment"("mapResultId"); + +-- CreateIndex +CREATE INDEX "ScoutingHeroAssignment_playerName_idx" ON "public"."ScoutingHeroAssignment"("playerName"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScoutingHeroAssignment_mapResultId_team_heroName_key" ON "public"."ScoutingHeroAssignment"("mapResultId", "team", "heroName"); + +-- AddForeignKey +ALTER TABLE "public"."ScoutingHeroAssignment" ADD CONSTRAINT "ScoutingHeroAssignment_mapResultId_fkey" FOREIGN KEY ("mapResultId") REFERENCES "public"."ScoutingMapResult"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260225175035_add_scouting_team_abbr/migration.sql b/prisma/migrations/20260225175035_add_scouting_team_abbr/migration.sql new file mode 100644 index 000000000..4b5813540 --- /dev/null +++ b/prisma/migrations/20260225175035_add_scouting_team_abbr/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Team" ADD COLUMN "scoutingTeamAbbr" TEXT; diff --git a/prisma/migrations/20260225202605_add_opponent_team_abbr_to_scrim/migration.sql b/prisma/migrations/20260225202605_add_opponent_team_abbr_to_scrim/migration.sql new file mode 100644 index 000000000..a83ff5e03 --- /dev/null +++ b/prisma/migrations/20260225202605_add_opponent_team_abbr_to_scrim/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Scrim" ADD COLUMN "opponentTeamAbbr" TEXT; diff --git a/prisma/migrations/20260227024556_add_scouting_player/migration.sql b/prisma/migrations/20260227024556_add_scouting_player/migration.sql new file mode 100644 index 000000000..b7456835e --- /dev/null +++ b/prisma/migrations/20260227024556_add_scouting_player/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "public"."ScoutingPlayer" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "team" TEXT NOT NULL, + "status" TEXT NOT NULL, + "role" TEXT NOT NULL, + "country" TEXT NOT NULL, + "signatureHeroes" TEXT[], + "winnings" INTEGER NOT NULL DEFAULT 0, + "region" TEXT NOT NULL, + "playerUrl" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScoutingPlayer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScoutingPlayer_playerUrl_key" ON "public"."ScoutingPlayer"("playerUrl"); + +-- CreateIndex +CREATE INDEX "ScoutingPlayer_name_idx" ON "public"."ScoutingPlayer"("name"); + +-- CreateIndex +CREATE INDEX "ScoutingPlayer_team_idx" ON "public"."ScoutingPlayer"("team"); + +-- CreateIndex +CREATE INDEX "ScoutingPlayer_region_idx" ON "public"."ScoutingPlayer"("region"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ceeaff16..f9f1cd4dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -197,6 +197,7 @@ model Team { readonly Boolean @default(false) comparisonGroups ComparisonGroup[] mapGroups MapGroup[] + scoutingTeamAbbr String? } model TeamManager { @@ -261,16 +262,17 @@ model MapGroup { } model Scrim { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - date DateTime - maps Map[] - Team Team? @relation(fields: [teamId], references: [id]) - teamId Int? - creatorId String - guestMode Boolean @default(false) + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + date DateTime + maps Map[] + Team Team? @relation(fields: [teamId], references: [id]) + teamId Int? + creatorId String + guestMode Boolean @default(false) + opponentTeamAbbr String? @@index([teamId]) } @@ -897,3 +899,165 @@ 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[] + rosters ScoutingRoster[] + 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? + vods Json @default("[]") + matchRoomUrl 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 + team1Comp String[] @default([]) + team2Comp String[] @default([]) + heroBans ScoutingHeroBan[] + heroAssignments ScoutingHeroAssignment[] + 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]) +} + +model ScoutingHeroAssignment { + id Int @id @default(autoincrement()) + mapResultId Int + team String + heroName String + playerName String + mapResult ScoutingMapResult @relation(fields: [mapResultId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([mapResultId, team, heroName]) + @@index([mapResultId]) + @@index([playerName]) +} + +enum RosterCategory { + player + staff + substitute +} + +enum RosterRole { + DPS + Tank + Support + Flex + Coach + HeadCoach + AssistantCoach + Manager + AssistantManager + TeamManager + Analyst + PerformanceCoach + Substitute +} + +model ScoutingRoster { + id Int @id @default(autoincrement()) + tournamentId Int + teamName String + tournament ScoutingTournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade) + players ScoutingRosterPlayer[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tournamentId, teamName]) + @@index([tournamentId]) + @@index([teamName]) +} + +model ScoutingRosterPlayer { + id Int @id @default(autoincrement()) + rosterId Int + name String + displayName String + role RosterRole + country String + didNotPlay Boolean @default(false) + category RosterCategory + roster ScoutingRoster @relation(fields: [rosterId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([rosterId]) + @@index([displayName]) +} + +model ScoutingPlayer { + id Int @id @default(autoincrement()) + name String + team String + status String + role String + country String + signatureHeroes String[] + winnings Int @default(0) + region String + playerUrl String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) + @@index([team]) + @@index([region]) +} diff --git a/scripts/seed-scouting-data.ts b/scripts/seed-scouting-data.ts new file mode 100644 index 000000000..4884402f2 --- /dev/null +++ b/scripts/seed-scouting-data.ts @@ -0,0 +1,425 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { + type MapType, + type RosterCategory, + type RosterRole, +} from "@prisma/client"; +import { JsonValue } from "@prisma/client/runtime/library"; +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 VodData { + url: string; + platform: string; +} + +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; + vods: VodData[]; + matchRoomUrl: string | null; +} + +interface PersonData { + name: string; + displayName: string; + role: string; + country: string; + didNotPlay: boolean; +} + +interface ParticipantData { + team: string; + players: PersonData[]; + staff: PersonData[]; + substitutes: PersonData[]; +} + +interface TournamentData { + title: string; + sourceUrl: string; + mapPool: Record; + matches: MatchData[]; + participants: ParticipantData[]; +} + +const VALID_MAP_TYPES: Set = new Set([ + "Clash", + "Control", + "Escort", + "Flashpoint", + "Hybrid", + "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; + } + 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; +} + +const ROLE_MAP: Record = { + DPS: "DPS", + Tank: "Tank", + Support: "Support", + Flex: "Flex", + Coach: "Coach", + "Head Coach": "HeadCoach", + "Assistant Coach": "AssistantCoach", + Manager: "Manager", + "Assistant Manager": "AssistantManager", + "Team Manager": "TeamManager", + Analyst: "Analyst", + "Performance Coach": "PerformanceCoach", + Substitute: "Substitute", +}; + +function toRosterRole(raw: string): RosterRole | null { + return ROLE_MAP[raw] ?? null; +} + +function toRosterPlayerRows(people: PersonData[], category: RosterCategory) { + return people.flatMap((person) => { + const role = toRosterRole(person.role); + if (!role) { + console.log( + ` Skipping person "${person.name}" with unknown role "${person.role}"` + ); + return []; + } + return [ + { + name: person.name, + displayName: person.displayName, + role, + country: person.country, + didNotPlay: person.didNotPlay, + category, + }, + ]; + }); +} + +async function seedRosters( + tournamentId: number, + participants: ParticipantData[] +) { + let rosterCount = 0; + let playerCount = 0; + + for (const participant of participants) { + const allPlayers = [ + ...toRosterPlayerRows(participant.players, "player"), + ...toRosterPlayerRows(participant.staff, "staff"), + ...toRosterPlayerRows(participant.substitutes, "substitute"), + ]; + + await prisma.scoutingRoster.create({ + data: { + tournamentId, + teamName: participant.team, + players: { createMany: { data: allPlayers } }, + }, + }); + + rosterCount++; + playerCount += allPlayers.length; + } + + return { rosterCount, playerCount }; +} + +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: cleanTeamName(match.team1FullName), + team2: match.team2, + team2FullName: cleanTeamName(match.team2FullName), + team1Score: match.team1Score, + team2Score: match.team2Score, + bestOf: match.bestOf, + winner: match.winner, + winnerFullName: cleanTeamName(match.winnerFullName), + matchDate, + mvp: match.mvp, + vods: (match.vods as unknown as JsonValue[]) ?? [], + matchRoomUrl: match.matchRoomUrl ?? null, + }, + }); + + 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) { + const existingRosterCount = await prisma.scoutingRoster.count({ + where: { tournamentId: existing.id }, + }); + + if ( + existingRosterCount === 0 && + (tournament.participants?.length ?? 0) > 0 + ) { + console.log(` Backfilling rosters for "${tournament.title}"...`); + const rosterResult = await seedRosters( + existing.id, + tournament.participants + ); + console.log( + ` Backfilled ${rosterResult.rosterCount} rosters, ${rosterResult.playerCount} players` + ); + return { + matches: 0, + maps: 0, + heroBans: 0, + rosters: rosterResult.rosterCount, + players: rosterResult.playerCount, + skipped: false, + }; + } + + console.log(` Skipping "${tournament.title}" (already exists)`); + return { + matches: 0, + maps: 0, + heroBans: 0, + rosters: 0, + players: 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; + let rosterCount = 0; + let playerCount = 0; + + try { + const rosterResult = await seedRosters( + createdTournament.id, + tournament.participants ?? [] + ); + rosterCount = rosterResult.rosterCount; + playerCount = rosterResult.playerCount; + + 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, + rosters: rosterCount, + players: playerCount, + 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 = JSON.parse(raw) as TournamentData[]; + + console.log(`Found ${tournaments.length} tournaments to process\n`); + + let totalMatches = 0; + let totalMaps = 0; + let totalHeroBans = 0; + let totalRosters = 0; + let totalPlayers = 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; + totalRosters += result.rosters; + totalPlayers += result.players; + console.log( + ` Inserted ${result.rosters} rosters, ${result.players} players, ${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 rosters: ${totalRosters}`); + console.log(`Total players: ${totalPlayers}`); + 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(), + prisma.scoutingRoster.count(), + prisma.scoutingRosterPlayer.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]}`); + console.log(` ScoutingRoster: ${dbCounts[4]}`); + console.log(` ScoutingRosterPlayer: ${dbCounts[5]}`); +} + +main() + .catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-scouting-players.ts b/scripts/seed-scouting-players.ts new file mode 100644 index 000000000..33ec25dde --- /dev/null +++ b/scripts/seed-scouting-players.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +interface PlayerData { + name: string; + team: string; + status: string; + role: string; + country: string; + signatureHeroes: string[]; + winnings: string; + region: string; + playerUrl: string; +} + +function parseWinnings(raw: string): number { + return parseInt(raw.replace(/[$,]/g, ""), 10) || 0; +} + +async function main() { + const dataPath = resolve( + process.env.HOME ?? "~", + "code/scouting-scraper/data/json/all_players.json" + ); + + console.log(`Reading data from ${dataPath}\n`); + + const raw = readFileSync(dataPath, "utf-8"); + const players = JSON.parse(raw) as PlayerData[]; + + console.log(`Found ${players.length} players to process\n`); + + let inserted = 0; + let updated = 0; + let errors = 0; + + for (const player of players) { + try { + const existing = await prisma.scoutingPlayer.findUnique({ + where: { playerUrl: player.playerUrl }, + }); + + await prisma.scoutingPlayer.upsert({ + where: { playerUrl: player.playerUrl }, + create: { + name: player.name, + team: player.team, + status: player.status, + role: player.role, + country: player.country, + signatureHeroes: player.signatureHeroes, + winnings: parseWinnings(player.winnings), + region: player.region, + playerUrl: player.playerUrl, + }, + update: { + name: player.name, + team: player.team, + status: player.status, + role: player.role, + country: player.country, + signatureHeroes: player.signatureHeroes, + winnings: parseWinnings(player.winnings), + region: player.region, + }, + }); + + if (existing) { + updated++; + } else { + inserted++; + } + } catch (error) { + errors++; + console.error( + ` Error processing "${player.name}" (${player.playerUrl}):`, + error instanceof Error ? error.message : String(error) + ); + } + } + + console.log("\n" + "=".repeat(50)); + console.log("SEED SUMMARY"); + console.log("=".repeat(50)); + console.log(`Players inserted: ${inserted}`); + console.log(`Players updated: ${updated}`); + console.log(`Errors: ${errors}`); + console.log("=".repeat(50)); + + const dbCount = await prisma.scoutingPlayer.count(); + console.log(`\nDatabase total: ${dbCount} scouting players`); +} + +main() + .catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 1e41eb9ac..c7bc890dc 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -2,9 +2,11 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { DangerZone } from "@/components/scrim/danger-zone"; import { EditScrimForm } from "@/components/scrim/edit-scrim-form"; import { Link } from "@/components/ui/link"; +import { getScoutingTeams } from "@/data/scouting-dto"; import { getScrim } from "@/data/scrim-dto"; import { getTeamsWithPerms } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import type { Route } from "next"; import { getTranslations } from "next-intl/server"; @@ -21,7 +23,13 @@ export default async function EditScrimPage( return
{t("scrimNotFound")}
; } - const teamsWithPerms = await getTeamsWithPerms(session?.user?.email); + const [teamsWithPerms, scoutingTeamList, scoutingEnabled] = await Promise.all( + [ + getTeamsWithPerms(session?.user?.email), + getScoutingTeams(), + scoutingTool(), + ] + ); const maps = ( await prisma.map.findMany({ @@ -74,6 +82,11 @@ export default async function EditScrimPage( scrim={scrim} teams={teamsWithPerms} maps={mapsWithHeroBans} + scoutingEnabled={scoutingEnabled} + scoutingTeams={scoutingTeamList.map((st) => ({ + abbreviation: st.abbreviation, + fullName: st.fullName, + }))} />
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/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx index a127508b2..94bdfa026 100644 --- a/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/page.tsx @@ -5,6 +5,7 @@ import { CompareSelectedButton } from "@/components/scrim/compare-selected-butto import { MapCardWithSelection } from "@/components/scrim/map-card-with-selection"; import { ScrimOverviewCard } from "@/components/scrim/scrim-overview-card"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Link } from "@/components/ui/link"; import { Tooltip, @@ -18,6 +19,7 @@ import { mapComparison, overviewCard } from "@/lib/flags"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@prisma/client"; +import { BadgeCheck } from "lucide-react"; import { ExclamationTriangleIcon, Pencil2Icon } from "@radix-ui/react-icons"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; @@ -111,10 +113,34 @@ export default async function ScrimDashboardPage( }, })) ?? { guestMode: false }; - const [mapComparisonEnabled, overviewCardEnabled] = await Promise.all([ - mapComparison(), - overviewCard(), - ]); + const [mapComparisonEnabled, overviewCardEnabled, opponentFullName] = + await Promise.all([ + mapComparison(), + overviewCard(), + scrim.opponentTeamAbbr + ? prisma.scoutingMatch + .findFirst({ + where: { + OR: [ + { team1: scrim.opponentTeamAbbr }, + { team2: scrim.opponentTeamAbbr }, + ], + }, + select: { + team1: true, + team1FullName: true, + team2: true, + team2FullName: true, + }, + }) + .then((m) => { + if (!m) return scrim.opponentTeamAbbr; + return m.team1 === scrim.opponentTeamAbbr + ? m.team1FullName + : m.team2FullName; + }) + : Promise.resolve(null), + ]); return ( @@ -151,9 +177,35 @@ export default async function ScrimDashboardPage(
-

- -

+
+

+ +

+ {scrim.opponentTeamAbbr && ( + + + + + + + + View OWCS scouting report + + )} +
{/* Overview Card */} diff --git a/src/app/api/data-labeling/matches/route.ts b/src/app/api/data-labeling/matches/route.ts new file mode 100644 index 000000000..e9df74106 --- /dev/null +++ b/src/app/api/data-labeling/matches/route.ts @@ -0,0 +1,134 @@ +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 type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/data-labeling/matches", + 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 page = Math.max( + 0, + Number(request.nextUrl.searchParams.get("page") ?? "0") + ); + const pageSize = Math.min( + 50, + Math.max(1, Number(request.nextUrl.searchParams.get("pageSize") ?? "20")) + ); + + wideEvent.request_params = { page, page_size: pageSize }; + + const where = { + NOT: { + vods: { equals: "[]" as unknown as undefined }, + }, + maps: { + some: { + OR: [ + { team1Comp: { isEmpty: true } }, + { team2Comp: { isEmpty: true } }, + ], + }, + }, + }; + + const [matches, totalCount] = await Promise.all([ + prisma.scoutingMatch.findMany({ + where, + include: { + maps: { select: { id: true, team1Comp: true, team2Comp: true } }, + tournament: { select: { title: true } }, + }, + orderBy: { matchDate: "desc" }, + skip: page * pageSize, + take: pageSize, + }), + prisma.scoutingMatch.count({ where }), + ]); + + const result = { + matches: matches.map((m) => { + const vods = m.vods as { url: string; platform: string }[]; + const labeledMaps = m.maps.filter( + (map) => map.team1Comp.length > 0 && map.team2Comp.length > 0 + ).length; + + return { + id: m.id, + team1: m.team1, + team1FullName: m.team1FullName, + team2: m.team2, + team2FullName: m.team2FullName, + team1Score: m.team1Score, + team2Score: m.team2Score, + matchDate: m.matchDate, + tournament: m.tournament.title, + vodCount: vods.length, + labeledMaps, + totalMaps: m.maps.length, + }; + }), + totalCount, + page, + pageSize, + }; + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.result = { + match_count: result.matches.length, + total_count: totalCount, + page, + }; + + return NextResponse.json(result); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + Logger.error("Error fetching unlabeled matches", error); + return NextResponse.json( + { success: false, error: "Failed to fetch matches" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} 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..dea9b9717 --- /dev/null +++ b/src/app/api/data-labeling/save-comp/route.ts @@ -0,0 +1,188 @@ +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 HeroAssignmentSchema = z.object({ + team: z.enum(["team1", "team2"]), + heroName: z.enum(heroNames), + playerName: z.string().min(1), +}); + +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), + heroAssignments: z.array(HeroAssignmentSchema).optional(), +}); + +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, heroAssignments } = body.data; + + wideEvent.request_params = { + map_result_id: mapResultId, + team1_comp: team1Comp, + team2_comp: team2Comp, + hero_assignment_count: heroAssignments?.length ?? 0, + }; + + 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.$transaction(async (tx) => { + await tx.scoutingMapResult.update({ + where: { id: mapResultId }, + data: { team1Comp, team2Comp }, + }); + + if (heroAssignments && heroAssignments.length > 0) { + await tx.scoutingHeroAssignment.deleteMany({ + where: { mapResultId }, + }); + await tx.scoutingHeroAssignment.createMany({ + data: heroAssignments.map((a) => ({ + mapResultId, + team: a.team, + heroName: a.heroName, + playerName: a.playerName, + })), + }); + } + }); + + 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, + hero_assignment_count: heroAssignments?.length ?? 0, + }; + + 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); + } +} diff --git a/src/app/api/scouting/get-teams/route.ts b/src/app/api/scouting/get-teams/route.ts new file mode 100644 index 000000000..e54c3b66d --- /dev/null +++ b/src/app/api/scouting/get-teams/route.ts @@ -0,0 +1,25 @@ +import { getScoutingTeams } from "@/data/scouting-dto"; +import { auth } from "@/lib/auth"; +import { unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; + +export type GetScoutingTeamsResponse = { + teams: { abbreviation: string; fullName: string }[]; +}; + +export async function GET() { + const session = await auth(); + + if (!session) { + unauthorized(); + } + + const scoutingTeams = await getScoutingTeams(); + + const teams = scoutingTeams.map((t) => ({ + abbreviation: t.abbreviation, + fullName: t.fullName, + })); + + return NextResponse.json({ teams }); +} diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 9c175fd7a..8a282f4ed 100644 --- a/src/app/api/scrim/create-scrim/route.ts +++ b/src/app/api/scrim/create-scrim/route.ts @@ -21,6 +21,7 @@ export type CreateScrimRequestData = { date: string; map: ParserData; replayCode: string; + opponentTeamAbbr?: string | null; heroBans: { hero: string; team: string; diff --git a/src/app/api/scrim/get-scrims/route.ts b/src/app/api/scrim/get-scrims/route.ts index 78b57e1ae..776974bbf 100644 --- a/src/app/api/scrim/get-scrims/route.ts +++ b/src/app/api/scrim/get-scrims/route.ts @@ -196,6 +196,7 @@ export async function GET(req: NextRequest) { `https://avatar.vercel.sh/${scrim.Team?.name}.png`, creator: creatorMap.get(scrim.creatorId) ?? "Unknown", hasPerms, + opponentTeamAbbr: scrim.opponentTeamAbbr, }; }); diff --git a/src/app/api/scrim/update-scrim-options/route.ts b/src/app/api/scrim/update-scrim-options/route.ts index 2a2a33870..0cc08181e 100644 --- a/src/app/api/scrim/update-scrim-options/route.ts +++ b/src/app/api/scrim/update-scrim-options/route.ts @@ -14,6 +14,7 @@ const UpdateScrimSchema = z.object({ scrimId: z.number(), date: z.iso.datetime(), guestMode: z.boolean(), + opponentTeamAbbr: z.string().nullable().optional(), maps: z.array( z.object({ id: z.number(), @@ -66,6 +67,7 @@ export async function POST(req: NextRequest) { teamId: parseInt(body.data.teamId), date: new Date(body.data.date), guestMode: body.data.guestMode, + opponentTeamAbbr: body.data.opponentTeamAbbr ?? null, }, }); diff --git a/src/app/api/team/update-name/route.ts b/src/app/api/team/update-name/route.ts index 175aaad98..06a22e4b1 100644 --- a/src/app/api/team/update-name/route.ts +++ b/src/app/api/team/update-name/route.ts @@ -18,6 +18,7 @@ const TeamNameUpdateSchema = z.object({ .regex(/^(?!.*?:).*$/), teamId: z.number(), readonly: z.boolean(), + scoutingTeamAbbr: z.string().nullable().optional(), }); export async function POST(req: NextRequest) { @@ -34,6 +35,7 @@ export async function POST(req: NextRequest) { data: { name: body.data.name, readonly: body.data.readonly, + scoutingTeamAbbr: body.data.scoutingTeamAbbr ?? null, }, }); 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/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 ( +
+
+ +
+
+ ); +} diff --git a/src/app/data-labeling/page.tsx b/src/app/data-labeling/page.tsx new file mode 100644 index 000000000..b0862c996 --- /dev/null +++ b/src/app/data-labeling/page.tsx @@ -0,0 +1,23 @@ +import { UnlabeledMatchList } from "@/components/data-labeling/unlabeled-match-list"; +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"); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+
+ ); +} 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 (
- +
diff --git a/src/app/scouting/layout.tsx b/src/app/scouting/layout.tsx new file mode 100644 index 000000000..b4029c0df --- /dev/null +++ b/src/app/scouting/layout.tsx @@ -0,0 +1,34 @@ +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..653c35410 --- /dev/null +++ b/src/app/scouting/page.tsx @@ -0,0 +1,25 @@ +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(); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+
+ ); +} diff --git a/src/app/scouting/player/[slug]/layout.tsx b/src/app/scouting/player/[slug]/layout.tsx new file mode 100644 index 000000000..0eac47411 --- /dev/null +++ b/src/app/scouting/player/[slug]/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/player/[slug]"> +): Promise { + const params = await props.params; + const t = await getTranslations("scoutingPage.player.profile.metadata"); + const locale = await getLocale(); + + const player = decodeURIComponent(params.slug); + + return { + title: t("title", { player }), + description: t("description", { player }), + openGraph: { + title: t("ogTitle", { player }), + description: t("ogDescription", { player }), + url: "https://parsertime.app", + type: "website", + siteName: "Parsertime", + images: [ + { + url: `https://parsertime.app/api/og?title=${encodeURIComponent(t("ogTitle", { player }))}`, + width: 1200, + height: 630, + }, + ], + locale, + }, + }; +} + +export default function ScoutingPlayerLayout({ + children, +}: LayoutProps<"/scouting/player/[slug]">) { + return children; +} diff --git a/src/app/scouting/player/[slug]/loading.tsx b/src/app/scouting/player/[slug]/loading.tsx new file mode 100644 index 000000000..3ba03f7f4 --- /dev/null +++ b/src/app/scouting/player/[slug]/loading.tsx @@ -0,0 +1,135 @@ +/* eslint-disable react/no-array-index-key */ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ScoutingPlayerLoading() { + return ( +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+
+ + {/* Hero Pool skeleton */} + + + + + +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+
+ + {/* Scrim Overview skeleton */} + + + + + + +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ + {/* Performance Radar skeleton */} + + + + + + + + + + + {/* Hero Z-Scores skeleton */} + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ + {/* Map Winrates skeleton */} + + + + + + + + + + {/* Tournament History skeleton */} + + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + +
+ ); +} diff --git a/src/app/scouting/player/[slug]/page.tsx b/src/app/scouting/player/[slug]/page.tsx new file mode 100644 index 000000000..9585ab84c --- /dev/null +++ b/src/app/scouting/player/[slug]/page.tsx @@ -0,0 +1,80 @@ +import { PlayerHeroPool } from "@/components/scouting/player-hero-pool"; +import { PlayerHeroZScores } from "@/components/scouting/player-hero-zscores"; +import { PlayerKillAnalysis } from "@/components/scouting/player-kill-analysis"; +import { PlayerMapWinrates } from "@/components/scouting/player-map-winrates"; +import { PlayerPerformanceRadar } from "@/components/scouting/player-performance-radar"; +import { PlayerProfileHeader } from "@/components/scouting/player-profile-header"; +import { PlayerScrimOverview } from "@/components/scouting/player-scrim-overview"; +import { PlayerStrengthsWeaknesses } from "@/components/scouting/player-strengths-weaknesses"; +import { PlayerTournamentHistory } from "@/components/scouting/player-tournament-history"; +import { getPlayerScoutingAnalytics } from "@/data/player-scouting-analytics-dto"; +import { getPlayerProfile } from "@/data/player-scouting-dto"; +import { scoutingTool } from "@/lib/flags"; +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 ScoutingPlayerPage( + props: PageProps<"/scouting/player/[slug]"> +) { + const scoutingEnabled = await scoutingTool(); + if (!scoutingEnabled) notFound(); + + const params = await props.params; + const slug = decodeURIComponent(params.slug); + const t = await getTranslations("scoutingPage.player.profile"); + + const profile = await getPlayerProfile(slug); + if (!profile) notFound(); + + const analytics = await getPlayerScoutingAnalytics(profile.name); + + return ( +
+
+ +
+ + + + + + {analytics.scrimData && ( + <> + + + + )} + + + + {analytics.scrimData && ( + + )} + + + + +
+ ); +} diff --git a/src/app/scouting/player/page.tsx b/src/app/scouting/player/page.tsx new file mode 100644 index 000000000..7ae521a0b --- /dev/null +++ b/src/app/scouting/player/page.tsx @@ -0,0 +1,25 @@ +import { PlayerSearch } from "@/components/scouting/player-search"; +import { getScoutingPlayers } from "@/data/player-scouting-dto"; +import { scoutingTool } from "@/lib/flags"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export default async function ScoutPlayerPage() { + const scoutingEnabled = await scoutingTool(); + if (!scoutingEnabled) notFound(); + + const t = await getTranslations("scoutingPage.player"); + const players = await getScoutingPlayers(); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+
+ ); +} 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..45250c4f9 --- /dev/null +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -0,0 +1,250 @@ +import { BanStrategy } from "@/components/scouting/ban-strategy"; +import { HeroBanChart } from "@/components/scouting/hero-ban-chart"; +import { MapVetoAdvisor } from "@/components/scouting/map-veto-advisor"; +import { MatchHistoryTable } from "@/components/scouting/match-history-table"; +import { MethodologyCard } from "@/components/scouting/methodology-card"; +import { PlayerMatchups } from "@/components/scouting/player-matchups"; +import { ScoutForTeamPicker } from "@/components/scouting/scout-for-team-picker"; +import { ScoutingReport } from "@/components/scouting/scouting-report"; +import { TeamOverviewEnhanced } from "@/components/scouting/team-overview-enhanced"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getHeroBanIntelligence } from "@/data/hero-ban-intelligence-dto"; +import { getMapIntelligence } from "@/data/map-intelligence-dto"; +import { + getTeamStrengthRating, + getTeamStrengthPercentile, +} from "@/data/opponent-strength-dto"; +import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; +import { getScoutingTeamProfile } from "@/data/scouting-dto"; +import { auth } from "@/lib/auth"; +import { resolveDataAvailability } from "@/lib/data-availability"; +import { scoutingTool } from "@/lib/flags"; +import { generateInsights } from "@/lib/insights"; +import prisma from "@/lib/prisma"; +import { ArrowLeft } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +type UserTeamOption = { id: number; name: string }; + +async function getUserTeams(): Promise<{ + teams: UserTeamOption[]; + userId: string | null; +}> { + const session = await auth(); + if (!session?.user?.email) return { teams: [], userId: null }; + + const user = await prisma.user.findFirst({ + where: { email: session.user.email }, + select: { id: true }, + }); + if (!user) return { teams: [], userId: null }; + + const teams = await prisma.team.findMany({ + where: { + OR: [ + { ownerId: user.id }, + { users: { some: { id: user.id } } }, + { managers: { some: { userId: user.id } } }, + ], + id: { not: 0 }, + }, + select: { id: true, name: true, scoutingTeamAbbr: true }, + orderBy: { updatedAt: "desc" }, + }); + + return { + teams: teams.map((t) => ({ id: t.id, name: t.name })), + userId: user.id, + }; +} + +function resolveScoutForTeamId( + scoutForParam: string | undefined, + userTeams: UserTeamOption[] +): number | null { + if (!scoutForParam) return null; + const parsed = parseInt(scoutForParam, 10); + if (Number.isNaN(parsed)) return null; + if (!userTeams.some((t) => t.id === parsed)) return null; + return parsed; +} + +export default async function ScoutingTeamPage( + props: PageProps<"/scouting/team/[teamAbbr]"> & { + searchParams: Promise<{ scoutFor?: string }>; + } +) { + const scoutingEnabled = await scoutingTool(); + if (!scoutingEnabled) notFound(); + + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + const teamAbbr = decodeURIComponent(params.teamAbbr); + const t = await getTranslations("scoutingPage.team"); + + const profile = await getScoutingTeamProfile(teamAbbr); + if (!profile) notFound(); + + const { overview } = profile; + const { teams: userTeams } = await getUserTeams(); + + const userTeamId = resolveScoutForTeamId(searchParams.scoutFor, userTeams); + const hasUserTeamLink = userTeamId !== null; + + const [strengthRating, strengthPercentile, dataAvailability] = + await Promise.all([ + getTeamStrengthRating(teamAbbr), + getTeamStrengthPercentile(teamAbbr), + resolveDataAvailability(teamAbbr, userTeamId), + ]); + + const [mapIntelligence, banIntelligence, playerIntelligence] = + await Promise.all([ + getMapIntelligence(teamAbbr, userTeamId, dataAvailability), + getHeroBanIntelligence(teamAbbr, userTeamId, dataAvailability), + userTeamId + ? getPlayerIntelligence(userTeamId, teamAbbr, dataAvailability) + : Promise.resolve(null), + ]); + + const insightReport = generateInsights({ + mapIntelligence, + banIntelligence, + playerIntelligence, + strengthRating, + opponentAbbr: teamAbbr, + hasUserTeamLink, + dataAvailability, + }); + + return ( +
+
+
+ +
+ +
+
+

+ {profile.team.abbreviation} +

+ + {profile.team.fullName} + +
+
+ + {overview.wins}W – {overview.losses}L + + + {overview.winRate.toFixed(1)}% {t("overview.winRate")} + + + {overview.weightedWinRate.toFixed(1)}%{" "} + {t("overview.weightedWinRate")} + + +
+
+
+ + + + {t("tabs.overview")} + {t("tabs.maps")} + {t("tabs.heroBans")} + {t("tabs.players")} + {t("tabs.report")} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +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} + + ); +} diff --git a/src/app/team/[teamId]/page.tsx b/src/app/team/[teamId]/page.tsx index 4b7fbc0bf..6442eaa8e 100644 --- a/src/app/team/[teamId]/page.tsx +++ b/src/app/team/[teamId]/page.tsx @@ -10,8 +10,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { getScoutingTeams } from "@/data/scouting-dto"; import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; +import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@prisma/client"; @@ -63,7 +65,13 @@ export default async function Team( const teamId = parseInt(params.teamId); - const [teamData, teamMembersData, teamManagers] = await Promise.all([ + const [ + teamData, + teamMembersData, + teamManagers, + scoutingEnabled, + scoutingTeams, + ] = await Promise.all([ prisma.team.findFirst({ where: { id: teamId } }), prisma.team.findFirst({ where: { id: teamId }, @@ -87,6 +95,8 @@ export default async function Team( }, }), prisma.teamManager.findMany({ where: { teamId } }), + scoutingTool(), + getScoutingTeams(), ]); const teamMembers = teamMembersData ?? { users: [] }; @@ -168,7 +178,11 @@ export default async function Team(
- +
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/create-scrim.tsx b/src/components/dashboard/create-scrim.tsx index 57a0c5dd6..857a85845 100644 --- a/src/components/dashboard/create-scrim.tsx +++ b/src/components/dashboard/create-scrim.tsx @@ -27,7 +27,7 @@ export function CreateScrimButton() { - + {t("createScrim")} diff --git a/src/components/dashboard/main-nav.tsx b/src/components/dashboard/main-nav.tsx index f7e9cbd97..5708cce9c 100644 --- a/src/components/dashboard/main-nav.tsx +++ b/src/components/dashboard/main-nav.tsx @@ -9,20 +9,21 @@ 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"; -export function MainNav({ className }: React.HTMLAttributes) { +export function MainNav({ + scoutingEnabled, + className, +}: React.HTMLAttributes & { scoutingEnabled: boolean }) { const pathname = usePathname(); const t = useTranslations("dashboard.mainNav"); - const isMobile = useIsMobile(); return ( @@ -136,6 +137,54 @@ export function MainNav({ className }: React.HTMLAttributes) { {t("teams")} + {scoutingEnabled && ( + + + {t("scouting")} + + +
    +
  • + + + {t("scoutTeam")} + + + + + {t("scoutPlayer")} + + +
  • +
+
+
+ )} -

- {t("team")} - {scrim.team} -

+
+

+ {t("team")} + {scrim.team} +

+ {scrim.opponentTeamAbbr && ( + <> + + + + e.stopPropagation()} + > + + + + + {t("viewScoutingReport")} + + + )} +
) { e.preventDefault(); const file = e.target.files ? e.target.files[0] : null; @@ -185,6 +200,7 @@ export function ScrimCreationForm({ resolver: zodResolver(FormSchema), defaultValues: { date: new Date(), + opponentTeamAbbr: null, heroBans: [], }, }); @@ -243,12 +259,13 @@ export function ScrimCreationForm({ return (
- +
+ {/* Scrim Name — full width */} ( - +
{t("scrimName")} )} /> + + {/* Team + Date — side by side */} @@ -313,7 +332,7 @@ export function ScrimCreationForm({ variant="outline" aria-invalid={fieldState.invalid} className={cn( - "w-[240px] pl-3 text-left font-normal", + "w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground" )} > @@ -341,11 +360,37 @@ export function ScrimCreationForm({ )} /> + + {/* Opponent (OWCS) + Map upload — side by side */} + {scoutingTeams.length > 0 && ( + ( + + Opponent (OWCS) + + + Optional. Link to an OWCS team to enable scouting analytics. + + + )} + /> + )} ( - + {t("mapName")} await handleFile(e)); }} type="file" - className="w-64" + className="w-full" accept=".xlsx, .txt" /> {t("mapDescription")} @@ -394,7 +439,11 @@ export function ScrimCreationForm({ } return ( - + {t("heroBansName")} @@ -474,7 +523,7 @@ export function ScrimCreationForm({ ); }} /> -
+
- +
); } 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..9bbaac929 --- /dev/null +++ b/src/components/data-labeling/hero-comp-picker.tsx @@ -0,0 +1,200 @@ +"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[]; + assignments?: Record; +}; + +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, + assignments, +}: 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); + const assignedPlayer = hero ? assignments?.[hero] : undefined; + return ( +
+
+ {hero ? ( + {hero} + ) : ( + + {t(role.toLowerCase() as "tank" | "damage" | "support")} + + )} +
+ + {assignedPlayer ?? "\u00A0"} + +
+ ); + })} + + {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 ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} 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..89482c2db --- /dev/null +++ b/src/components/data-labeling/match-labeling-view.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { HeroCompPicker } from "@/components/data-labeling/hero-comp-picker"; +import { PlayerAssignmentPanel } from "@/components/data-labeling/player-assignment-panel"; +import { autoSuggestAssignments } from "@/components/data-labeling/roster-role-utils"; +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, RotateCcw } from "lucide-react"; +import type { Route } from "next"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import Link from "next/link"; +import { memo, useCallback, useState } from "react"; +import { toast } from "sonner"; + +type MatchLabelingViewProps = { + match: MatchForLabeling; +}; + +type MapCompState = { + team1Comp: string[]; + team2Comp: string[]; + team1Assignments: Record; + team2Assignments: Record; + 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; +} + +type VodPanelProps = { + vod: MatchForLabeling["vods"][number] | undefined; + vodSource: string | null; + parentDomain: string; +}; + +const VodPanel = memo(function VodPanel({ + vod, + vodSource, + parentDomain, +}: VodPanelProps) { + const t = useTranslations("dataLabeling.labeling"); + + return ( +
+ {vod && vodSource === "youtube" && ( + + +
+ +
+
+
+ )} + + {vod && vodSource === "twitch" && ( + + +
+