diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 3fc37eeee..5739a9dd4 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -1,32 +1,17 @@ import { AppHeader } from "@/components/app-header"; -import { MapCharts } from "@/components/charts/map/map-charts"; import { DirectionalTransition } from "@/components/directional-transition"; -import { ComparePlayers } from "@/components/map/compare-players"; -import { DefaultOverview } from "@/components/map/default-overview"; -import { FightInitiationInspector } from "@/components/map/fight-initiation-inspector"; -import { HeatmapTab } from "@/components/map/heatmap/heatmap-tab"; +import { ActiveMapTab } from "@/components/map/active-map-tab"; import { HeroBans } from "@/components/map/hero-bans"; -import { Killfeed } from "@/components/map/killfeed"; -import { MapEvents } from "@/components/map/map-events"; import { MapTabs } from "@/components/map/map-tabs"; import { MapTabsSkeleton } from "@/components/map/map-tabs-skeleton"; -import { MatchStoryTab } from "@/components/map/match-story/match-story-tab"; import { PlayerSwitcher } from "@/components/map/player-switcher"; -import { ReplayTab } from "@/components/map/replay/replay-tab"; -import { RoutesTab } from "@/components/map/routes/routes-tab"; import { ReplayCode } from "@/components/scrim/replay-code"; -import { TipTap } from "@/components/tiptap/tiptap"; import { StatsViewBeacon } from "@/components/usage/stats-view-beacon"; -import { VodOverview } from "@/components/vods/vod-overview"; import { MatchStoryService } from "@/data/map/match-story-service"; import { PlayerService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth, isAuthedToViewMap } from "@/lib/auth"; -import { - getFightInitiationForMapData, - type MapInitiationResult, -} from "@/lib/fight-initiation"; import { positionalData, tempoChart } from "@/lib/flags"; import { resolveScrimMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; @@ -120,7 +105,6 @@ export default async function MapDashboardPage( tempoChartEnabled, positionalDataEnabled, matchStory, - fightInitiation, ] = await Promise.all([ AppRuntime.runPromise( PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) @@ -156,21 +140,42 @@ export default async function MapDashboardPage( Effect.catchAll(() => Effect.succeed(null)) ) ), - getFightInitiationForMapData(mapDataId).catch( - () => - ({ - available: false, - labels: [], - summary: null, - rounds: [], - }) satisfies MapInitiationResult - ), ]); const translatedMapName = await translateMapName( mapDetails?.map_name ?? "Map" ); + // Tab triggers are always shown; only the active tab's content is rendered. + const tabs = [ + { value: "overview", label: t("tabs.overview") }, + { value: "killfeed", label: t("tabs.killfeed") }, + { value: "charts", label: t("tabs.charts") }, + ...(matchStory !== null + ? [{ value: "story", label: t("tabs.story") }] + : []), + ...(positionalDataEnabled + ? [ + { value: "heatmap", label: t("tabs.heatmap") }, + { value: "replay", label: t("tabs.replay") }, + { value: "routes", label: t("tabs.routes") }, + ] + : []), + { value: "events", label: t("tabs.events"), className: "hidden md:flex" }, + { value: "initiation", label: t("tabs.initiation") }, + { value: "compare", label: t("tabs.compare") }, + { value: "notes", label: t("tabs.notes") }, + { value: "vods", label: t("tabs.vod") }, + ]; + + // Resolve the active tab from `?tab=`, falling back to overview for unknown + // or feature-gated values. + const requestedTab = + typeof searchParams.tab === "string" ? searchParams.tab : "overview"; + const activeTab = tabs.some((tab) => tab.value === requestedTab) + ? requestedTab + : "overview"; + return ( @@ -209,125 +214,32 @@ export default async function MapDashboardPage( )} - - - - } - > - - - ), - }, - { - value: "killfeed", - label: t("tabs.killfeed"), - content: ( - - ), - }, - { - value: "charts", - label: t("tabs.charts"), - content: ( - - ), - }, - ...(matchStory !== null - ? [ - { - value: "story", - label: t("tabs.story"), - content: ( - - ), - }, - ] - : []), - ...(positionalDataEnabled - ? [ - { - value: "heatmap", - label: t("tabs.heatmap"), - content: , - }, - { - value: "replay", - label: t("tabs.replay"), - content: , - }, - { - value: "routes", - label: t("tabs.routes"), - content: , - }, - ] - : []), - { - value: "events", - label: t("tabs.events"), - className: "hidden md:flex", - content: ( - - ), - }, - { - value: "initiation", - label: t("tabs.initiation"), - content: ( - - ), - }, - { - value: "compare", - label: t("tabs.compare"), - content: , - }, - { - value: "notes", - label: t("tabs.notes"), - content: ( - - ), - }, - { - value: "vods", - label: t("tabs.vod"), - content: , - }, - ]} - /> - - + + + + + + } + > + + + + diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx index cdb613a9b..3e2c90cd1 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx @@ -96,30 +96,35 @@ export default async function ScrimDashboardPage( ) { const params = await props.params; const id = parseInt(params.scrimId); + if (!Number.isSafeInteger(id) || id <= 0) notFound(); const session = await auth(); const t = await getTranslations("scrimPage"); - const scrim = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(id))) - ); - if (!scrim) notFound(); - - const teamId = scrim.teamId; - - const maps = await prisma.map.findMany({ - where: { - scrimId: id, - }, - orderBy: [{ order: "asc" }, { id: "asc" }], - }); - - // Per-map team names (from the round's MatchStart) power the winner-override - // dialog. The set-winner endpoint validates the submitted winner against the - // map's own MatchStart names, so the dialog must offer them verbatim. Keyed - // by Map.id via MapData. - const mapTeamNames = new Map(); - if (maps.length > 0) { - const mapDataRows = await prisma.mapData.findMany({ + // Independent reads — none depends on another (the user lookup only needs the + // session, already resolved), so fetch them together instead of in a chain. + const [ + scrim, + maps, + mapDataRows, + user, + feedbackScrim, + visibilityRow, + mapComparisonEnabled, + overviewCardEnabled, + showPositional, + ] = await Promise.all([ + AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(id))) + ), + prisma.map.findMany({ + where: { scrimId: id }, + orderBy: [{ order: "asc" }, { id: "asc" }], + }), + // Per-map team names (from the round's MatchStart) power the winner-override + // dialog. The set-winner endpoint validates the submitted winner against the + // map's own MatchStart names, so the dialog must offer them verbatim. Keyed + // by Map.id via MapData. + prisma.mapData.findMany({ where: { scrimId: id }, select: { Map: { select: { id: true } }, @@ -128,31 +133,88 @@ export default async function ScrimDashboardPage( take: 1, }, }, - }); - for (const row of mapDataRows) { - const mapId = row.Map?.id; - const ms = row.match_start[0]; - if (mapId != null && ms && !mapTeamNames.has(mapId)) { - mapTeamNames.set(mapId, { - team1: ms.team_1_name, - team2: ms.team_2_name, - }); - } + }), + AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session?.user?.email)) + ) + ), + prisma.scrim.findUnique({ + where: { id }, + select: { + id: true, + teamId: true, + opponentTeamId: true, + feedback: { select: { id: true } }, + opponentTeam: { select: { name: true } }, + }, + }), + prisma.scrim.findFirst({ + where: { id }, + select: { guestMode: true }, + }), + mapComparison(), + overviewCard(), + positionalData(), + ]); + if (!scrim) notFound(); + + const teamId = scrim.teamId; + const visibility = visibilityRow ?? { guestMode: false }; + + const mapTeamNames = new Map(); + for (const row of mapDataRows) { + const mapId = row.Map?.id; + const ms = row.match_start[0]; + if (mapId != null && ms && !mapTeamNames.has(mapId)) { + mapTeamNames.set(mapId, { + team1: ms.team_1_name, + team2: ms.team_2_name, + }); } } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) - ); + // Permission + context reads that depend on the scrim/user above. The overview + // gate (totalScrimCount) is grouped here too: the roster-identity heuristic + // (getTeamRoster) anchors on the most-frequent player across the team's maps, + // which can't reliably tell which side is "our team" until the team has at + // least two scrims. Mirror the team stats page (totalScrimCount < 2 -> + // placeholder) and skip the overview for new teams rather than render a + // possibly-inverted record. + const [isManagerRecord, canManage, opponentFullName, totalScrimCount] = + await Promise.all([ + teamId + ? prisma.teamManager.findFirst({ where: { teamId, userId: user?.id } }) + : Promise.resolve(null), + canManageTeam(feedbackScrim?.teamId, user), + 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), + teamId ? prisma.scrim.count({ where: { teamId } }) : Promise.resolve(0), + ]); - const isManager = teamId - ? (await prisma.teamManager.findFirst({ - where: { - teamId, - userId: user?.id, - }, - })) !== null && session !== null - : false; + const isManager = + teamId !== null ? isManagerRecord !== null && session !== null : false; const hasPerms = user?.id === scrim?.creatorId || @@ -160,80 +222,44 @@ export default async function ScrimDashboardPage( user?.role === $Enums.UserRole.MANAGER || user?.role === $Enums.UserRole.ADMIN; - const feedbackScrim = await prisma.scrim.findUnique({ - where: { id }, - select: { - id: true, - teamId: true, - opponentTeamId: true, - feedback: { select: { id: true } }, - opponentTeam: { select: { name: true } }, - }, - }); - - const canManage = await canManageTeam(feedbackScrim?.teamId, user); - - const visibility = (await prisma.scrim.findFirst({ - where: { - id: parseInt(params.scrimId), - }, - select: { - guestMode: true, - }, - })) ?? { guestMode: false }; - - const [ - mapComparisonEnabled, - overviewCardEnabled, - showPositional, - opponentFullName, - ] = await Promise.all([ - mapComparison(), - overviewCard(), - positionalData(), - 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), - ]); - - // The overview's roster-identity heuristic (getTeamRoster) anchors on the - // most-frequent player across the team's maps, which can't reliably tell - // which side is "our team" until the team has at least two scrims. Mirror - // the team stats page (totalScrimCount < 2 -> placeholder) and skip the - // overview for new teams rather than render a possibly-inverted record. - const totalScrimCount = teamId - ? await prisma.scrim.count({ where: { teamId } }) - : 0; const isNewTeam = teamId !== null && totalScrimCount < 2; - const overviewData = - overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam - ? await AppRuntime.runPromise( - ScrimOverviewService.pipe( - Effect.flatMap((svc) => svc.getScrimOverview(id, teamId)) + // Heavy reads — independent of one another, so run them together instead of + // four sequential awaits. + const [overviewData, positionalStats, positionalArtifacts, scrimInitiation] = + await Promise.all([ + overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam + ? AppRuntime.runPromise( + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(id, teamId)) + ) + ) + : Promise.resolve(null), + showPositional && maps.length > 0 + ? AppRuntime.runPromise( + ScrimPositionalStatsService.pipe( + Effect.flatMap((svc) => svc.getScrimPositionalStats(id)) + ) + ) + : Promise.resolve(null), + showPositional && maps.length > 0 && teamId + ? AppRuntime.runPromise( + ScrimPositionalArtifactsService.pipe( + Effect.flatMap((svc) => + svc.getScrimPositionalArtifacts(id, teamId) + ) + ) + ) + : Promise.resolve(null), + overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam + ? AppRuntime.runPromise( + ScrimInitiationService.pipe( + Effect.flatMap((svc) => svc.getScrimInitiation(id)) + ) ) - ) - : null; + : Promise.resolve(null), + ]); + const showOverview = overviewData !== null && overviewData.mapCount > 0 && @@ -241,33 +267,6 @@ export default async function ScrimDashboardPage( const showOverviewUnavailable = overviewCardEnabled && isNewTeam && maps.length > 0; - const positionalStats = - showPositional && maps.length > 0 - ? await AppRuntime.runPromise( - ScrimPositionalStatsService.pipe( - Effect.flatMap((svc) => svc.getScrimPositionalStats(id)) - ) - ) - : null; - - const positionalArtifacts = - showPositional && maps.length > 0 && teamId - ? await AppRuntime.runPromise( - ScrimPositionalArtifactsService.pipe( - Effect.flatMap((svc) => svc.getScrimPositionalArtifacts(id, teamId)) - ) - ) - : null; - - const scrimInitiation = - overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam - ? await AppRuntime.runPromise( - ScrimInitiationService.pipe( - Effect.flatMap((svc) => svc.getScrimInitiation(id)) - ) - ) - : null; - // Build per-map winner metadata for the map cards' W/L badge + override // dialog. The resolved winner prefers the stored Map.winner (manual override // or auto-detected); otherwise it falls back to the overview's resolved diff --git a/apps/web/src/app/api/scrim/get-scrims/route.ts b/apps/web/src/app/api/scrim/get-scrims/route.ts index baa9e443c..4e22b73c8 100644 --- a/apps/web/src/app/api/scrim/get-scrims/route.ts +++ b/apps/web/src/app/api/scrim/get-scrims/route.ts @@ -88,14 +88,19 @@ export async function GET(req: NextRequest) { userData.role === $Enums.UserRole.ADMIN || userData.role === $Enums.UserRole.MANAGER; - // Build where clause for database filtering - // Exclude synthetic scrims created for tournament matches - const whereClause: Prisma.ScrimWhereInput = { + // Base scope (team + tournament exclusion) without the search filter, so we + // can cheaply tell "no scrims at all" apart from "no scrims after filters" + // without a second request from the client. + // Exclude synthetic scrims created for tournament matches. + const baseWhere: Prisma.ScrimWhereInput = { tournamentMatch: null, }; // Filter by team if teamId is provided - if (teamId) whereClause.teamId = teamId; + if (teamId) baseWhere.teamId = teamId; + + // Build where clause for database filtering (base scope + search) + const whereClause: Prisma.ScrimWhereInput = { ...baseWhere }; // Apply search filter at database level if (search) { @@ -258,6 +263,31 @@ export async function GET(req: NextRequest) { }; }); + // Distinguish "no scrims at all" (onboarding) from "no scrims after + // filters" (no-results message). Only pay for the existence check when the + // filtered page came back empty — if there are results there are scrims. + let hasAnyScrims = scrimDetails.length > 0; + if (!hasAnyScrims) { + const existing = await prisma.scrim.findFirst({ + where: + adminMode && isAdmin + ? baseWhere + : { + AND: [ + { + OR: [ + { creatorId: userData.id }, + { Team: { users: { some: { id: userData.id } } } }, + ], + }, + baseWhere, + ], + }, + select: { id: true }, + }); + hasAnyScrims = existing !== null; + } + const nextCursor = scrims[scrims.length - 1]?.id; // Calculate hasMore based on pagination method @@ -276,6 +306,7 @@ export async function GET(req: NextRequest) { nextCursor: nextCursor?.toString() ?? undefined, hasMore, totalCount, + hasAnyScrims, }); } catch (error) { Logger.error("Error fetching scrims:", error); diff --git a/apps/web/src/app/stats/team/[teamId]/loading.tsx b/apps/web/src/app/stats/team/[teamId]/loading.tsx index 7c8a3a0d9..06a8ead02 100644 --- a/apps/web/src/app/stats/team/[teamId]/loading.tsx +++ b/apps/web/src/app/stats/team/[teamId]/loading.tsx @@ -1,5 +1,8 @@ -/* oxlint-disable react/no-array-index-key */ -import { Skeleton } from "@/components/ui/skeleton"; +import { + SkeletonRibbon, + SkeletonSection, + SkeletonTable, +} from "@/components/stats/team/overview-skeletons"; // The header and tab nav live in the persistent layout, so this loading // boundary only skeletons the page content that streams in per tab. @@ -12,58 +15,3 @@ export default function TeamStatsLoading() { ); } - -function SkeletonRibbon() { - return ( - - {Array.from({ length: 4 }).map((_, i) => ( - - - - - - ))} - - ); -} - -function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { - return ( - - - - - - - - ); -} - -function SkeletonTable({ rows }: { rows: number }) { - return ( - - - - - - - - {Array.from({ length: rows }).map((_, i) => ( - - - - - - - - - - - ))} - - - ); -} diff --git a/apps/web/src/app/stats/team/[teamId]/page.tsx b/apps/web/src/app/stats/team/[teamId]/page.tsx index 288cca742..5c2c9e33b 100644 --- a/apps/web/src/app/stats/team/[teamId]/page.tsx +++ b/apps/web/src/app/stats/team/[teamId]/page.tsx @@ -1,20 +1,16 @@ -import { MapPerformanceTable } from "@/components/stats/team/map-performance-table"; -import { OverviewInsightsBand } from "@/components/stats/team/overview-insights-band"; -import { QuickStatsRibbon } from "@/components/stats/team/quick-stats-ribbon"; -import { TeamRosterGrid } from "@/components/stats/team/team-roster-grid"; -import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; -import { AppRuntime } from "@/data/runtime"; import { - TeamHeroPoolService, - TeamQuickWinsService, - TeamRoleStatsService, - TeamSharedDataService, - TeamStatsService, -} from "@/data/team"; -import { getTempoBaselines } from "@/lib/tempo/read"; -import { getMapNames } from "@/lib/utils"; + OverviewAnalysisSection, + QuickStatsRibbonSection, + TeamRosterSection, +} from "@/components/stats/team/overview-sections"; +import { + SkeletonRibbon, + SkeletonSection, + SkeletonTable, +} from "@/components/stats/team/overview-skeletons"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; import type { PagePropsWithLocale } from "@/types/next"; -import { Effect } from "effect"; +import { Suspense } from "react"; import { loadTeamStatsShell } from "./_lib/context"; // The heaviest dashboard in the app: dozens of services over a shared @@ -36,103 +32,33 @@ export default async function TeamStatsOverviewPage( const { teamId, dateRange, isManager, substituteNames } = shell; - const [ - { - quickStats, - roleStats, - roleBalance, - bestMapByWinrate, - blindSpotMap, - top5Maps, - allMapsPlaytime, - heroPool, - teamRoster, - winrates, - }, - mapNames, - ] = await Promise.all([ - AppRuntime.runPromise( - Effect.all( - { - quickStats: TeamQuickWinsService.pipe( - Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) - ), - roleStats: TeamRoleStatsService.pipe( - Effect.flatMap((svc) => - svc.getRolePerformanceStats(teamId, dateRange) - ) - ), - roleBalance: TeamRoleStatsService.pipe( - Effect.flatMap((svc) => - svc.getRoleBalanceAnalysis(teamId, dateRange) - ) - ), - bestMapByWinrate: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getBestMapByWinrate(teamId, dateRange)) - ), - blindSpotMap: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getBlindSpotMap(teamId, dateRange)) - ), - top5Maps: TeamStatsService.pipe( - Effect.flatMap((svc) => - svc.getTop5MapsByPlaytime(teamId, dateRange) - ) - ), - allMapsPlaytime: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) - ), - heroPool: TeamHeroPoolService.pipe( - Effect.flatMap((svc) => - svc.getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to) - ) - ), - teamRoster: TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getTeamRoster(teamId)) - ), - winrates: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) - ), - }, - { concurrency: "unbounded" } - ) - ), - getMapNames(), - ]); - - const baselines = await getTempoBaselines(); - + // Stream each block independently: the above-the-fold ribbon paints as soon + // as its (light) reads resolve, while the heavier analysis and roster reads + // continue in the background instead of blocking the whole page's first byte. return ( - - - + }> + + - + + + + > + } + > + + - + }> + + ); } diff --git a/apps/web/src/components/dashboard/scrim-pagination.tsx b/apps/web/src/components/dashboard/scrim-pagination.tsx index 27d79b4d2..b41ecef4b 100644 --- a/apps/web/src/components/dashboard/scrim-pagination.tsx +++ b/apps/web/src/components/dashboard/scrim-pagination.tsx @@ -56,6 +56,8 @@ type ScrimResponse = { nextCursor?: string; hasMore: boolean; totalCount: number; + /** Whether the user has any scrims at all (ignoring search/filter). */ + hasAnyScrims: boolean; }; export function ScrimPagination({ @@ -184,33 +186,6 @@ export function ScrimPagination({ }, }); - // Check if user has any scrims at all (without filters) - const { data: totalScrimsData } = useQuery({ - queryKey: isAdmin ? ["admin-scrims-total"] : ["scrims-total", teamId], - queryFn: async () => { - const params = new URLSearchParams(); - params.set("limit", "1"); - - if (isAdmin) { - params.set("adminMode", "true"); - } - - if (teamId && !isAdmin) { - params.set("teamId", teamId.toString()); - } - - const response = await fetch( - `/api/scrim/get-scrims?${params.toString()}` - ); - - if (!response.ok) { - throw new Error("Failed to fetch scrims"); - } - - return response.json() as Promise; - }, - }); - const totalCount = data?.totalCount ?? 0; const pagination = handlePagination({ @@ -241,7 +216,7 @@ export function ScrimPagination({ } // Show empty scrim list only if user has no scrims at all - if (!isLoading && totalScrimsData?.totalCount === 0) { + if (!isLoading && data?.hasAnyScrims === false) { return ; } diff --git a/apps/web/src/components/map/active-map-tab.tsx b/apps/web/src/components/map/active-map-tab.tsx new file mode 100644 index 000000000..a9a288975 --- /dev/null +++ b/apps/web/src/components/map/active-map-tab.tsx @@ -0,0 +1,129 @@ +import { MapCharts } from "@/components/charts/map/map-charts"; +import { ComparePlayers } from "@/components/map/compare-players"; +import { DefaultOverview } from "@/components/map/default-overview"; +import { FightInitiationInspector } from "@/components/map/fight-initiation-inspector"; +import { HeatmapTab } from "@/components/map/heatmap/heatmap-tab"; +import { Killfeed } from "@/components/map/killfeed"; +import { MapEvents } from "@/components/map/map-events"; +import { MatchStoryTab } from "@/components/map/match-story/match-story-tab"; +import { ReplayTab } from "@/components/map/replay/replay-tab"; +import { RoutesTab } from "@/components/map/routes/routes-tab"; +import { TipTap } from "@/components/tiptap/tiptap"; +import { VodOverview } from "@/components/vods/vod-overview"; +import type { MatchStoryResult } from "@/data/map/match-story-service"; +import { + getFightInitiationForMapData, + type MapInitiationResult, +} from "@/lib/fight-initiation"; + +type ActiveMapTabProps = { + /** The tab currently selected via the `?tab=` search param. */ + activeTab: string; + id: number; + mapDataId: number; + scrimId: number; + team1Color: string; + team2Color: string; + tempoChartEnabled: boolean; + positionalDataEnabled: boolean; + matchStory: MatchStoryResult | null; + noteContent: string; + vod: string; +}; + +/** + * Renders the content for the single active map tab. Only the selected tab's + * server component is rendered (and only it fetches its data), so the map page + * no longer pays for every tab's queries on each load. Switching tabs is a + * shallow:false navigation that re-renders this with a new `activeTab`. + */ +export async function ActiveMapTab({ + activeTab, + id, + mapDataId, + scrimId, + team1Color, + team2Color, + tempoChartEnabled, + positionalDataEnabled, + matchStory, + noteContent, + vod, +}: ActiveMapTabProps) { + switch (activeTab) { + case "killfeed": + return ( + + ); + case "charts": + return ( + + ); + case "story": + // The trigger is only shown when a story exists, but guard defensively. + return matchStory !== null ? ( + + ) : null; + case "heatmap": + return ; + case "replay": + return ; + case "routes": + return ; + case "events": + return ( + + ); + case "initiation": { + // Only computed when the initiation tab is open — it is one of the + // heavier reads and is rarely the landing tab. + const fightInitiation = await getFightInitiationForMapData( + mapDataId + ).catch( + () => + ({ + available: false, + labels: [], + summary: null, + rounds: [], + }) satisfies MapInitiationResult + ); + return ; + } + case "compare": + return ; + case "notes": + return ( + + ); + case "vods": + return ; + case "overview": + default: + return ( + + ); + } +} diff --git a/apps/web/src/components/map/map-tabs.tsx b/apps/web/src/components/map/map-tabs.tsx index e8a39b225..069c7075e 100644 --- a/apps/web/src/components/map/map-tabs.tsx +++ b/apps/web/src/components/map/map-tabs.tsx @@ -11,43 +11,54 @@ type TabDef = { shortLabel?: string; hidden?: boolean; className?: string; - content: ReactNode; }; type MapTabsProps = { tabs: TabDef[]; + /** The active tab, resolved server-side from the `?tab=` search param. */ + activeTab: string; + /** Server-rendered content for the active tab only. */ + children: ReactNode; }; -export function MapTabs({ tabs }: MapTabsProps) { +export function MapTabs({ tabs, activeTab, children }: MapTabsProps) { const t = useTranslations("mapPage"); - const [tab, setTab] = useQueryState( + // shallow:false so switching tabs re-runs the server page and renders the + // newly-selected tab's content, instead of shipping every tab up front. + const [, setTab] = useQueryState( "tab", - parseAsString.withDefault("overview") + parseAsString.withDefault("overview").withOptions({ shallow: false }) ); return ( - + void setTab(value)} + className="space-y-4" + > - {tabs.map((t) => - t.hidden ? null : ( - - {t.shortLabel ? ( + {tabs.map((tab) => + tab.hidden ? null : ( + + {tab.shortLabel ? ( <> - {t.label} - {t.shortLabel} + {tab.label} + {tab.shortLabel} > ) : ( - t.label + tab.label )} ) )} - {tabs.map((t) => ( - - {t.content} - - ))} + + {children} + ); } diff --git a/apps/web/src/components/stats/team/overview-sections.tsx b/apps/web/src/components/stats/team/overview-sections.tsx new file mode 100644 index 000000000..48ca6443e --- /dev/null +++ b/apps/web/src/components/stats/team/overview-sections.tsx @@ -0,0 +1,165 @@ +import { MapPerformanceTable } from "@/components/stats/team/map-performance-table"; +import { OverviewInsightsBand } from "@/components/stats/team/overview-insights-band"; +import { QuickStatsRibbon } from "@/components/stats/team/quick-stats-ribbon"; +import { TeamRosterGrid } from "@/components/stats/team/team-roster-grid"; +import { AppRuntime } from "@/data/runtime"; +import { + TeamHeroPoolService, + TeamQuickWinsService, + TeamRoleStatsService, + TeamSharedDataService, + TeamStatsService, +} from "@/data/team"; +import type { TeamDateRange } from "@/data/team/shared-core"; +import { getTempoBaselines } from "@/lib/tempo/read"; +import { getMapNames } from "@/lib/utils"; +import { Effect } from "effect"; + +// Each section fetches only its own slice and is rendered inside its own +// on the overview page, so the above-the-fold ribbon paints without +// waiting for the heavier analysis/roster reads. The expensive per-team base +// read is deduped across sections by the shared-data Effect cache, so the only +// duplicated work between sections is light in-memory derivation. + +type SectionProps = { + teamId: number; + dateRange: TeamDateRange | undefined; +}; + +export async function QuickStatsRibbonSection({ + teamId, + dateRange, +}: SectionProps) { + const [{ quickStats, heroPool, allMapsPlaytime }, baselines] = + await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + quickStats: TeamQuickWinsService.pipe( + Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) + ), + heroPool: TeamHeroPoolService.pipe( + Effect.flatMap((svc) => + svc.getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to) + ) + ), + allMapsPlaytime: TeamStatsService.pipe( + Effect.flatMap((svc) => + svc.getTopMapsByPlaytime(teamId, dateRange) + ) + ), + }, + { concurrency: 4 } + ) + ), + getTempoBaselines(), + ]); + + return ( + + ); +} + +export async function OverviewAnalysisSection({ + teamId, + dateRange, +}: SectionProps) { + const [ + { + quickStats, + roleStats, + roleBalance, + bestMapByWinrate, + blindSpotMap, + top5Maps, + winrates, + }, + mapNames, + ] = await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + quickStats: TeamQuickWinsService.pipe( + Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) + ), + roleStats: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => + svc.getRolePerformanceStats(teamId, dateRange) + ) + ), + roleBalance: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => + svc.getRoleBalanceAnalysis(teamId, dateRange) + ) + ), + bestMapByWinrate: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBestMapByWinrate(teamId, dateRange)) + ), + blindSpotMap: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBlindSpotMap(teamId, dateRange)) + ), + top5Maps: TeamStatsService.pipe( + Effect.flatMap((svc) => + svc.getTop5MapsByPlaytime(teamId, dateRange) + ) + ), + winrates: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ), + }, + { concurrency: 4 } + ) + ), + getMapNames(), + ]); + + return ( + <> + + + > + ); +} + +export async function TeamRosterSection({ + teamId, + isManager, + substituteNames, +}: { + teamId: number; + isManager: boolean; + substituteNames: Set; +}) { + const teamRoster = await AppRuntime.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ) + ); + + return ( + + ); +} diff --git a/apps/web/src/components/stats/team/overview-skeletons.tsx b/apps/web/src/components/stats/team/overview-skeletons.tsx new file mode 100644 index 000000000..8e69d2cf0 --- /dev/null +++ b/apps/web/src/components/stats/team/overview-skeletons.tsx @@ -0,0 +1,59 @@ +/* oxlint-disable react/no-array-index-key */ +import { Skeleton } from "@/components/ui/skeleton"; + +/** Streaming fallbacks for the team stats overview, shared by the route-level + * loading boundary and the per-section Suspense boundaries. */ +export function SkeletonRibbon() { + return ( + + {Array.from({ length: 4 }).map((_, i) => ( + + + + + + ))} + + ); +} + +export function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { + return ( + + + + + + + + ); +} + +export function SkeletonTable({ rows }: { rows: number }) { + return ( + + + + + + + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + + + + ))} + + + ); +} diff --git a/apps/web/src/lib/stat-percentiles.ts b/apps/web/src/lib/stat-percentiles.ts index d7e203a5c..f228a8017 100644 --- a/apps/web/src/lib/stat-percentiles.ts +++ b/apps/web/src/lib/stat-percentiles.ts @@ -1,6 +1,15 @@ import prisma from "@/lib/prisma"; import type { HeroName } from "@/types/heroes"; import { Prisma } from "@/generated/prisma/client"; +import { unstable_cache } from "next/cache"; + +// These baselines scan the whole PlayerStat table per hero (the app's biggest +// rows-read offender) yet are global, slowly-changing population statistics — +// they only move when new scrims are uploaded. Cache them with a TTL so the +// repeated stat-card reads collapse to one scan per (hero, params) window. The +// shared tag lets a future upload hook bust them eagerly via revalidateTag. +const HERO_BASELINE_TAG = "hero-baselines"; +const HERO_BASELINE_TTL_SECONDS = 60 * 60 * 6; // 6 hours type ValidStatColumn = | "eliminations" @@ -435,7 +444,7 @@ function buildMultiStatComparisonQuery({ `; } -export async function compareMultipleStatsToDistribution( +async function compareMultipleStatsToDistributionUncached( params: MultiStatComparisonParams ): Promise { const query = buildMultiStatComparisonQuery(params); @@ -454,6 +463,12 @@ export async function compareMultipleStatsToDistribution( }; } +export const compareMultipleStatsToDistribution = unstable_cache( + compareMultipleStatsToDistributionUncached, + ["multi-stat-comparison"], + { revalidate: HERO_BASELINE_TTL_SECONDS, tags: [HERO_BASELINE_TAG] } +); + function buildStatDistributionBaselineQuery({ hero, stat, @@ -507,7 +522,7 @@ function buildStatDistributionBaselineQuery({ `; } -export async function getStatDistributionBaseline( +async function getStatDistributionBaselineUncached( params: StatDistributionBaselineParams ): Promise { const query = buildStatDistributionBaselineQuery(params); @@ -515,6 +530,12 @@ export async function getStatDistributionBaseline( return result[0] ?? null; } +export const getStatDistributionBaseline = unstable_cache( + getStatDistributionBaselineUncached, + ["stat-distribution-baseline"], + { revalidate: HERO_BASELINE_TTL_SECONDS, tags: [HERO_BASELINE_TAG] } +); + export type { StatDistributionBaseline, StatDistributionBaselineParams,