From 4d31e11cbdc9fa890afb736692535e63948c5657 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:11:18 -0400 Subject: [PATCH 1/6] Render only the active map tab instead of every tab The map page built and server-rendered all 11 tab contents on every load, so each heavy tab (heatmap, replay, routes, events, compare, killfeed, charts, overview) ran its own queries even when the user never opened it. Render only the tab selected via ?tab= and stream it under Suspense; switching tabs is now a shallow:false navigation. Fight-initiation is computed only when its tab is open. --- .../scrim/[scrimId]/map/[mapId]/page.tsx | 202 +++++------------- .../web/src/components/map/active-map-tab.tsx | 129 +++++++++++ apps/web/src/components/map/map-tabs.tsx | 45 ++-- 3 files changed, 214 insertions(+), 162 deletions(-) create mode 100644 apps/web/src/components/map/active-map-tab.tsx 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/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} + ); } From 6f0c020afa8eb429c4fde814afbc770b7c976e05 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:13:46 -0400 Subject: [PATCH 2/6] Drop the dashboard's redundant total-scrims request ScrimPagination fired a second get-scrims request (limit=1, no filters) on every load and team switch purely to tell an empty account apart from empty filter results. The API now returns hasAnyScrims on the main response, computed with a cheap findFirst only when the filtered page is empty, so the client needs a single request instead of two. --- .../web/src/app/api/scrim/get-scrims/route.ts | 39 +++++++++++++++++-- .../components/dashboard/scrim-pagination.tsx | 31 ++------------- 2 files changed, 38 insertions(+), 32 deletions(-) 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/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 ; } From d540853b648a1179665efe994557b8626e47751f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:17:04 -0400 Subject: [PATCH 3/6] Parallelize the scrim page's independent reads The scrim overview page ran ~12 reads as a sequential await chain even though most don't depend on each other. Group them into three parallel tiers: the scrim/maps/user/feedback/visibility/flag reads, then the permission and context reads that need the scrim and user, then the four heavy overview/positional/ initiation reads that were previously awaited one after another. Behavior is unchanged; an early integer guard keeps malformed ids returning notFound before the batch runs. --- .../src/app/[team]/scrim/[scrimId]/page.tsx | 279 +++++++++--------- 1 file changed, 139 insertions(+), 140 deletions(-) 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 From 4138eaa4246623bda1006ea474b9a6d02858cae3 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:19:41 -0400 Subject: [PATCH 4/6] Bound the team stats page's panel concurrency The overview ran its ten panel services with concurrency:"unbounded". The shared base read is already deduped by an Effect cache, so firing everything at once mostly just lets the secondary per-panel queries spike the connection pool and stretch the tail past the 60s budget. Cap concurrency at 4. --- apps/web/src/app/stats/team/[teamId]/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/stats/team/[teamId]/page.tsx b/apps/web/src/app/stats/team/[teamId]/page.tsx index 288cca742..26a2e35b5 100644 --- a/apps/web/src/app/stats/team/[teamId]/page.tsx +++ b/apps/web/src/app/stats/team/[teamId]/page.tsx @@ -93,7 +93,11 @@ export default async function TeamStatsOverviewPage( Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) ), }, - { concurrency: "unbounded" } + // Bounded, not unbounded: the shared base read is deduped by an Effect + // Cache, so the gain from firing all panels at once is small, while the + // secondary per-panel queries can otherwise spike the connection pool + // and blow up the tail (observed p99 well past the 60s budget). + { concurrency: 4 } ) ), getMapNames(), From b488dc57e05d911eef1c8a7c400cf251767d5d3c Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:22:53 -0400 Subject: [PATCH 5/6] Stream the team stats overview in sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview awaited all ten panel reads before sending any HTML, so the page's first byte waited on the slowest service. Split it into three Suspense sections — quick-stats ribbon, the insights + map-performance analysis block, and the roster — each fetching only its own slice. The shared per-team base read is deduped by the existing Effect cache, so the above-the-fold ribbon paints while the heavier blocks stream in. Skeleton fragments are extracted so the route loading boundary and the per-section fallbacks share them. --- .../src/app/stats/team/[teamId]/loading.tsx | 62 +------ apps/web/src/app/stats/team/[teamId]/page.tsx | 146 ++++------------ .../stats/team/overview-sections.tsx | 165 ++++++++++++++++++ .../stats/team/overview-skeletons.tsx | 59 +++++++ 4 files changed, 263 insertions(+), 169 deletions(-) create mode 100644 apps/web/src/components/stats/team/overview-sections.tsx create mode 100644 apps/web/src/components/stats/team/overview-skeletons.tsx 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 26a2e35b5..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,107 +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)) - ), - }, - // Bounded, not unbounded: the shared base read is deduped by an Effect - // Cache, so the gain from firing all panels at once is small, while the - // secondary per-panel queries can otherwise spike the connection pool - // and blow up the tail (observed p99 well past the 60s budget). - { concurrency: 4 } - ) - ), - 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/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) => ( +
+ + +
+ + + + +
+
+ ))} +
+
+ ); +} From 65d6d414c6f6dfa8f6e771800d5b5a27c4f86c68 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:27:06 -0400 Subject: [PATCH 6/6] Cache the global hero stat baselines The hero stat-card percentile queries scan the whole PlayerStat table per hero and were the app's biggest rows-read offender (~38M rows/day) despite being global, slowly-changing population statistics. Wrap the two actively-used baseline functions in unstable_cache with a 6h TTL so repeated stat-card reads collapse to one scan per (hero, params) window. A shared hero-baselines tag is attached so an upload hook can bust them eagerly later via revalidateTag. --- apps/web/src/lib/stat-percentiles.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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,