From 86763dba8d6089429561f62a4284bb2fc2cd7bad Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 10:45:22 -0400 Subject: [PATCH 0001/1107] Require at least two scrims for team stats page With only one scrim uploaded, team-side detection is a coin-flip based on which side has the most roster players, and users have been asking for a way to manually flip the assignment. Short-circuit the page when the team has fewer than two scrims total and render a placeholder explaining the requirement instead. --- src/app/stats/team/[teamId]/page.tsx | 50 ++++++++++++++----- .../team/insufficient-scrims-placeholder.tsx | 41 +++++++++++++++ 2 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 src/components/stats/team/insufficient-scrims-placeholder.tsx diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx index ce12adf6d..f3e644d0d 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -1,8 +1,10 @@ import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar"; +import { AbilityImpactAnalysisCard } from "@/components/stats/team/ability-impact-analysis-card"; import { BestRoleTriosCard } from "@/components/stats/team/best-role-trios-card"; import { HeroBanImpactCard } from "@/components/stats/team/hero-ban-impact-card"; import { HeroOurBansCard } from "@/components/stats/team/hero-our-bans-card"; import { HeroPoolContainer } from "@/components/stats/team/hero-pool-container"; +import { InsufficientScrimsPlaceholder } from "@/components/stats/team/insufficient-scrims-placeholder"; import { MapModePerformanceCard } from "@/components/stats/team/map-mode-performance-card"; import { MapWinrateGallery } from "@/components/stats/team/map-winrate-gallery"; import { MatchupWinrateTab } from "@/components/stats/team/matchup-winrate-tab"; @@ -22,35 +24,34 @@ import { TeamFightStatsCard } from "@/components/stats/team/team-fight-stats-car import { TeamRangePicker } from "@/components/stats/team/team-range-picker"; import { TeamRosterGrid } from "@/components/stats/team/team-roster-grid"; import { TopMapsCard } from "@/components/stats/team/top-maps-card"; +import { UltImpactAnalysisCard } from "@/components/stats/team/ult-impact-analysis-card"; import { UltPlayerRankingsCard } from "@/components/stats/team/ult-player-rankings-card"; import { UltRoleBreakdownCard } from "@/components/stats/team/ult-role-breakdown-card"; -import { AbilityImpactAnalysisCard } from "@/components/stats/team/ability-impact-analysis-card"; -import { UltImpactAnalysisCard } from "@/components/stats/team/ult-impact-analysis-card"; import { UltUsageOverviewCard } from "@/components/stats/team/ult-usage-overview-card"; import { UltimateEconomyCard } from "@/components/stats/team/ultimate-economy-card"; import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-card"; import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights"; import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { TeamAnalyticsService, TeamPredictionService } from "@/data/team"; +import { AppRuntime } from "@/data/runtime"; import { + TeamAbilityImpactService, + TeamAnalyticsService, + TeamBanImpactService, type TeamDateRange, - TeamStatsService, TeamFightStatsService, - TeamRoleStatsService, - TeamTrendsService, - TeamMapModeService, - TeamQuickWinsService, TeamHeroPoolService, TeamHeroSwapService, - TeamBanImpactService, - TeamAbilityImpactService, - TeamUltService, + TeamMapModeService, TeamMatchupService, + TeamPredictionService, + TeamQuickWinsService, + TeamRoleStatsService, TeamSharedDataService, + TeamStatsService, + TeamTrendsService, + TeamUltService, } from "@/data/team"; -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { simulationTool, ultimateImpactTool } from "@/lib/flags"; @@ -62,6 +63,7 @@ import { getMapNames } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@prisma/client"; import { addMonths, addWeeks, addYears } from "date-fns"; +import { Effect } from "effect"; import Image from "next/image"; import { notFound } from "next/navigation"; @@ -140,6 +142,28 @@ export default async function TeamStatsPage( const userIsMember = team.users.some((teamUser) => teamUser.id === user.id); if (!userIsMember && user.role !== $Enums.UserRole.ADMIN) notFound(); + const totalScrimCount = await prisma.scrim.count({ where: { teamId } }); + + if (totalScrimCount < 2) { + return ( +
+
+
+ {team.name} +

{team.name}

+
+
+ +
+ ); + } + const [timeframe1, timeframe2, timeframe3] = await Promise.all([ new Permission("stats-timeframe-1").check(), new Permission("stats-timeframe-2").check(), diff --git a/src/components/stats/team/insufficient-scrims-placeholder.tsx b/src/components/stats/team/insufficient-scrims-placeholder.tsx new file mode 100644 index 000000000..31890b826 --- /dev/null +++ b/src/components/stats/team/insufficient-scrims-placeholder.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { BarChart3 } from "lucide-react"; +import Link from "next/link"; + +export function InsufficientScrimsPlaceholder({ + scrimCount, +}: { + scrimCount: number; +}) { + return ( + + + + + + At least two scrims required + + Team stats need at least two scrims to reliably identify which side + your team played on each map. You currently have{" "} + {scrimCount === 0 + ? "no scrims" + : `${scrimCount} scrim${scrimCount === 1 ? "" : "s"}`}{" "} + uploaded. Upload another to unlock the full stats dashboard. + + + + + + + ); +} From 1be8b1e8a7d39e7cc1b3163347e646b458f00e66 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 11:11:18 -0400 Subject: [PATCH 0002/1107] Add monthly active user and team charts to admin analytics Defines activity as being part of a team that has uploaded a scrim in the given month, which captures members who benefit from scrims without personally uploading them. Each subject (users, teams) gets a pie chart of active versus inactive this month and a 12-month line chart. --- messages/en.json | 16 ++ src/app/settings/admin/analytics/page.tsx | 169 ++++++++++++++++++ .../admin/active-teams-pie-chart.tsx | 78 ++++++++ .../admin/active-users-pie-chart.tsx | 78 ++++++++ .../admin/monthly-active-teams-chart.tsx | 72 ++++++++ .../admin/monthly-active-users-chart.tsx | 72 ++++++++ 6 files changed, 485 insertions(+) create mode 100644 src/components/admin/active-teams-pie-chart.tsx create mode 100644 src/components/admin/active-users-pie-chart.tsx create mode 100644 src/components/admin/monthly-active-teams-chart.tsx create mode 100644 src/components/admin/monthly-active-users-chart.tsx diff --git a/messages/en.json b/messages/en.json index 3490897b2..4b6d219c1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3005,6 +3005,22 @@ "analytics": { "title": "Analytics", "description": "View detailed analytics and charts for user activity and growth.", + "activeUsers": { + "title": "Monthly Active Users", + "description": "Users on a team that uploaded a scrim this month, versus the rest of the user base" + }, + "monthlyActiveUsers": { + "title": "Active Users Over Time", + "description": "Unique users each month whose team uploaded a scrim, over the last 12 months" + }, + "activeTeams": { + "title": "Monthly Active Teams", + "description": "Teams that uploaded a scrim this month, versus all teams" + }, + "monthlyActiveTeams": { + "title": "Active Teams Over Time", + "description": "Unique teams that uploaded a scrim each month, over the last 12 months" + }, "userGrowth": { "title": "User Growth Over Time", "description": "Monthly user registration trends over the last 12 months" diff --git a/src/app/settings/admin/analytics/page.tsx b/src/app/settings/admin/analytics/page.tsx index 07e1ca681..9e32e1dd5 100644 --- a/src/app/settings/admin/analytics/page.tsx +++ b/src/app/settings/admin/analytics/page.tsx @@ -1,4 +1,8 @@ +import { ActiveTeamsPieChart } from "@/components/admin/active-teams-pie-chart"; +import { ActiveUsersPieChart } from "@/components/admin/active-users-pie-chart"; import { BillingPlanPieChart } from "@/components/admin/billing-plan-pie-chart"; +import { MonthlyActiveTeamsChart } from "@/components/admin/monthly-active-teams-chart"; +import { MonthlyActiveUsersChart } from "@/components/admin/monthly-active-users-chart"; import { MonthlyUserChart } from "@/components/admin/monthly-user-chart"; import { ScrimActivityChart } from "@/components/admin/scrim-activity-chart"; import { SignupMethodPieChart } from "@/components/admin/signup-method-pie-chart"; @@ -240,6 +244,119 @@ async function getBillingPlanData() { })); } +function buildMonthWindows(now: Date) { + const windows: { monthStart: Date; monthEnd: Date; label: string }[] = []; + for (let i = 11; i >= 0; i--) { + const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); + const label = monthStart.toLocaleDateString("en-US", { month: "long" }); + windows.push({ monthStart, monthEnd, label }); + } + return windows; +} + +async function getUserActivityData() { + const now = new Date(); + const windows = buildMonthWindows(now); + + const [totalUsers, monthlyCounts] = await Promise.all([ + prisma.user.count(), + Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.user.count({ + where: { + teams: { + some: { + scrims: { + some: { createdAt: { gte: monthStart, lt: monthEnd } }, + }, + }, + }, + }, + }) + ) + ), + ]); + + const monthlyActivity = windows.map((w, i) => ({ + month: w.label, + activeUsers: monthlyCounts[i] ?? 0, + })); + + const currentMonthActive = + monthlyActivity[monthlyActivity.length - 1]?.activeUsers ?? 0; + const inactive = Math.max(totalUsers - currentMonthActive, 0); + const safeTotal = totalUsers > 0 ? totalUsers : 1; + + const pieData: { + status: "Active" | "Inactive"; + count: number; + percentage: number; + }[] = [ + { + status: "Active", + count: currentMonthActive, + percentage: Math.round((currentMonthActive / safeTotal) * 100), + }, + { + status: "Inactive", + count: inactive, + percentage: Math.round((inactive / safeTotal) * 100), + }, + ]; + + return { monthlyActivity, pieData }; +} + +async function getTeamActivityData() { + const now = new Date(); + const windows = buildMonthWindows(now); + + const [totalTeams, monthlyCounts] = await Promise.all([ + prisma.team.count(), + Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.team.count({ + where: { + scrims: { + some: { createdAt: { gte: monthStart, lt: monthEnd } }, + }, + }, + }) + ) + ), + ]); + + const monthlyActivity = windows.map((w, i) => ({ + month: w.label, + activeTeams: monthlyCounts[i] ?? 0, + })); + + const currentMonthActive = + monthlyActivity[monthlyActivity.length - 1]?.activeTeams ?? 0; + const inactive = Math.max(totalTeams - currentMonthActive, 0); + const safeTotal = totalTeams > 0 ? totalTeams : 1; + + const pieData: { + status: "Active" | "Inactive"; + count: number; + percentage: number; + }[] = [ + { + status: "Active", + count: currentMonthActive, + percentage: Math.round((currentMonthActive / safeTotal) * 100), + }, + { + status: "Inactive", + count: inactive, + percentage: Math.round((inactive / safeTotal) * 100), + }, + ]; + + return { monthlyActivity, pieData }; +} + export default async function AdminAnalyticsPage() { const session = await auth(); if (!session?.user) { @@ -264,6 +381,8 @@ export default async function AdminAnalyticsPage() { teamManagerData, signupMethodData, billingPlanData, + userActivityData, + teamActivityData, ] = await Promise.all([ getMonthlyUserData(), getScrimActivityData(), @@ -271,6 +390,8 @@ export default async function AdminAnalyticsPage() { getTeamManagerData(), getSignupMethodData(), getBillingPlanData(), + getUserActivityData(), + getTeamActivityData(), ]); return ( @@ -281,6 +402,54 @@ export default async function AdminAnalyticsPage() {
+
+ + + {t("activeUsers.title")} + {t("activeUsers.description")} + + + + + + + + {t("monthlyActiveUsers.title")} + + {t("monthlyActiveUsers.description")} + + + + + + +
+
+ + + {t("activeTeams.title")} + {t("activeTeams.description")} + + + + + + + + {t("monthlyActiveTeams.title")} + + {t("monthlyActiveTeams.description")} + + + + + + +
diff --git a/src/components/admin/active-teams-pie-chart.tsx b/src/components/admin/active-teams-pie-chart.tsx new file mode 100644 index 000000000..65be5f9ba --- /dev/null +++ b/src/components/admin/active-teams-pie-chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Cell, Pie, PieChart } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type ActiveTeamsPieData = { + status: "Active" | "Inactive"; + count: number; + percentage: number; +}; + +type ActiveTeamsPieChartProps = { + data: ActiveTeamsPieData[]; +}; + +export function ActiveTeamsPieChart({ data }: ActiveTeamsPieChartProps) { + const chartConfig: ChartConfig = { + Active: { + label: "Active", + color: "var(--chart-4)", + }, + Inactive: { + label: "Inactive", + color: "var(--chart-1)", + }, + }; + + const COLORS: Record = { + Active: "var(--chart-4)", + Inactive: "var(--chart-1)", + }; + + return ( + + + + {data.map((entry) => ( + + ))} + + } + formatter={(value, name) => { + const entry = data.find((d) => d.status === name); + return [ + `${String(value)} (${entry?.percentage ?? 0}%)`, + String(name), + ]; + }} + /> + } /> + + + ); +} diff --git a/src/components/admin/active-users-pie-chart.tsx b/src/components/admin/active-users-pie-chart.tsx new file mode 100644 index 000000000..7f4040f30 --- /dev/null +++ b/src/components/admin/active-users-pie-chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Cell, Pie, PieChart } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type ActiveUsersPieData = { + status: "Active" | "Inactive"; + count: number; + percentage: number; +}; + +type ActiveUsersPieChartProps = { + data: ActiveUsersPieData[]; +}; + +export function ActiveUsersPieChart({ data }: ActiveUsersPieChartProps) { + const chartConfig: ChartConfig = { + Active: { + label: "Active", + color: "var(--chart-2)", + }, + Inactive: { + label: "Inactive", + color: "var(--chart-1)", + }, + }; + + const COLORS: Record = { + Active: "var(--chart-2)", + Inactive: "var(--chart-1)", + }; + + return ( + + + + {data.map((entry) => ( + + ))} + + } + formatter={(value, name) => { + const entry = data.find((d) => d.status === name); + return [ + `${String(value)} (${entry?.percentage ?? 0}%)`, + String(name), + ]; + }} + /> + } /> + + + ); +} diff --git a/src/components/admin/monthly-active-teams-chart.tsx b/src/components/admin/monthly-active-teams-chart.tsx new file mode 100644 index 000000000..80b8fcd5a --- /dev/null +++ b/src/components/admin/monthly-active-teams-chart.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type MonthlyActiveTeamsData = { + month: string; + activeTeams: number; +}; + +type MonthlyActiveTeamsChartProps = { + data: MonthlyActiveTeamsData[]; +}; + +export function MonthlyActiveTeamsChart({ + data, +}: MonthlyActiveTeamsChartProps) { + const chartConfig: ChartConfig = { + activeTeams: { + label: "Active Teams", + color: "var(--chart-4)", + }, + }; + + return ( + + + + value.slice(0, 3)} + /> + + } /> + + + + ); +} diff --git a/src/components/admin/monthly-active-users-chart.tsx b/src/components/admin/monthly-active-users-chart.tsx new file mode 100644 index 000000000..45800198e --- /dev/null +++ b/src/components/admin/monthly-active-users-chart.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type MonthlyActiveUsersData = { + month: string; + activeUsers: number; +}; + +type MonthlyActiveUsersChartProps = { + data: MonthlyActiveUsersData[]; +}; + +export function MonthlyActiveUsersChart({ + data, +}: MonthlyActiveUsersChartProps) { + const chartConfig: ChartConfig = { + activeUsers: { + label: "Active Users", + color: "var(--chart-2)", + }, + }; + + return ( + + + + value.slice(0, 3)} + /> + + } /> + + + + ); +} From 2af3ce2a5bd408c9b1301265f0be4f44013ad5a6 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 11:22:10 -0400 Subject: [PATCH 0003/1107] Add 12-month vs all-time tabs to admin time-series charts Keeps the last-12-months view as the default and adds an all-time tab that extends back to the first user creation date. Applies to active users, active teams, user growth, and team creations. All-time tick labels include the year and use preserveStartEnd spacing so multi-year ranges stay readable. --- src/app/settings/admin/analytics/page.tsx | 212 +++++++++++------- .../admin/monthly-active-teams-chart.tsx | 49 +++- .../admin/monthly-active-users-chart.tsx | 49 +++- src/components/admin/monthly-user-chart.tsx | 44 +++- src/components/admin/team-creation-chart.tsx | 44 +++- 5 files changed, 272 insertions(+), 126 deletions(-) diff --git a/src/app/settings/admin/analytics/page.tsx b/src/app/settings/admin/analytics/page.tsx index 9e32e1dd5..c36efe931 100644 --- a/src/app/settings/admin/analytics/page.tsx +++ b/src/app/settings/admin/analytics/page.tsx @@ -26,32 +26,60 @@ import { $Enums } from "@prisma/client"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -async function getMonthlyUserData() { - const now = new Date(); - const monthlyData = []; - - // Get data for the last 12 months - for (let i = 11; i >= 0; i--) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - - const userCount = await prisma.user.count({ - where: { - createdAt: { - gte: monthStart, - lt: monthEnd, - }, - }, - }); - - const monthName = monthStart.toLocaleDateString("en-US", { month: "long" }); - monthlyData.push({ - month: monthName, - users: userCount, +type MonthWindow = { + monthStart: Date; + monthEnd: Date; + longLabel: string; + shortLabel: string; +}; + +function buildMonthWindowsRange( + startDate: Date, + endDate: Date = new Date() +): MonthWindow[] { + const cursor = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + const endCursor = new Date(endDate.getFullYear(), endDate.getMonth(), 1); + const windows: MonthWindow[] = []; + while (cursor.getTime() <= endCursor.getTime()) { + const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); + windows.push({ + monthStart: new Date(cursor), + monthEnd: next, + longLabel: cursor.toLocaleDateString("en-US", { month: "long" }), + shortLabel: cursor.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }), }); + cursor.setMonth(cursor.getMonth() + 1); } + return windows; +} + +async function getMonthlyUserData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); - return monthlyData; + const counts = await Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.user.count({ + where: { createdAt: { gte: monthStart, lt: monthEnd } }, + }) + ) + ); + + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + users: counts[i] ?? 0, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + users: lastTwelveCounts[i] ?? 0, + })); + + return { twelveMonth, historical }; } async function getScrimActivityData() { @@ -99,32 +127,30 @@ async function getScrimActivityData() { return dataArray; } -async function getTeamCreationData() { - const now = new Date(); - const monthlyData = []; - - // Get data for the last 12 months - for (let i = 11; i >= 0; i--) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - - const teamCount = await prisma.team.count({ - where: { - createdAt: { - gte: monthStart, - lt: monthEnd, - }, - }, - }); +async function getTeamCreationData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); - const monthName = monthStart.toLocaleDateString("en-US", { month: "long" }); - monthlyData.push({ - month: monthName, - teams: teamCount, - }); - } + const counts = await Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.team.count({ + where: { createdAt: { gte: monthStart, lt: monthEnd } }, + }) + ) + ); - return monthlyData; + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + teams: counts[i] ?? 0, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + teams: lastTwelveCounts[i] ?? 0, + })); + + return { twelveMonth, historical }; } async function getTeamManagerData() { @@ -244,22 +270,10 @@ async function getBillingPlanData() { })); } -function buildMonthWindows(now: Date) { - const windows: { monthStart: Date; monthEnd: Date; label: string }[] = []; - for (let i = 11; i >= 0; i--) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - const label = monthStart.toLocaleDateString("en-US", { month: "long" }); - windows.push({ monthStart, monthEnd, label }); - } - return windows; -} - -async function getUserActivityData() { - const now = new Date(); - const windows = buildMonthWindows(now); +async function getUserActivityData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); - const [totalUsers, monthlyCounts] = await Promise.all([ + const [totalUsers, counts] = await Promise.all([ prisma.user.count(), Promise.all( windows.map(({ monthStart, monthEnd }) => @@ -278,13 +292,19 @@ async function getUserActivityData() { ), ]); - const monthlyActivity = windows.map((w, i) => ({ - month: w.label, - activeUsers: monthlyCounts[i] ?? 0, + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + activeUsers: counts[i] ?? 0, })); - const currentMonthActive = - monthlyActivity[monthlyActivity.length - 1]?.activeUsers ?? 0; + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + activeUsers: lastTwelveCounts[i] ?? 0, + })); + + const currentMonthActive = counts[counts.length - 1] ?? 0; const inactive = Math.max(totalUsers - currentMonthActive, 0); const safeTotal = totalUsers > 0 ? totalUsers : 1; @@ -305,14 +325,13 @@ async function getUserActivityData() { }, ]; - return { monthlyActivity, pieData }; + return { monthlyActivity: { twelveMonth, historical }, pieData }; } -async function getTeamActivityData() { - const now = new Date(); - const windows = buildMonthWindows(now); +async function getTeamActivityData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); - const [totalTeams, monthlyCounts] = await Promise.all([ + const [totalTeams, counts] = await Promise.all([ prisma.team.count(), Promise.all( windows.map(({ monthStart, monthEnd }) => @@ -327,13 +346,19 @@ async function getTeamActivityData() { ), ]); - const monthlyActivity = windows.map((w, i) => ({ - month: w.label, - activeTeams: monthlyCounts[i] ?? 0, + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + activeTeams: counts[i] ?? 0, })); - const currentMonthActive = - monthlyActivity[monthlyActivity.length - 1]?.activeTeams ?? 0; + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + activeTeams: lastTwelveCounts[i] ?? 0, + })); + + const currentMonthActive = counts[counts.length - 1] ?? 0; const inactive = Math.max(totalTeams - currentMonthActive, 0); const safeTotal = totalTeams > 0 ? totalTeams : 1; @@ -354,7 +379,7 @@ async function getTeamActivityData() { }, ]; - return { monthlyActivity, pieData }; + return { monthlyActivity: { twelveMonth, historical }, pieData }; } export default async function AdminAnalyticsPage() { @@ -374,6 +399,13 @@ export default async function AdminAnalyticsPage() { } const t = await getTranslations("settingsPage.admin.analytics"); + + const firstUser = await prisma.user.findFirst({ + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }); + const firstUserDate = firstUser?.createdAt ?? new Date(); + const [ monthlyUserData, scrimActivityData, @@ -384,14 +416,14 @@ export default async function AdminAnalyticsPage() { userActivityData, teamActivityData, ] = await Promise.all([ - getMonthlyUserData(), + getMonthlyUserData(firstUserDate), getScrimActivityData(), - getTeamCreationData(), + getTeamCreationData(firstUserDate), getTeamManagerData(), getSignupMethodData(), getBillingPlanData(), - getUserActivityData(), - getTeamActivityData(), + getUserActivityData(firstUserDate), + getTeamActivityData(firstUserDate), ]); return ( @@ -421,7 +453,8 @@ export default async function AdminAnalyticsPage() { @@ -445,7 +478,8 @@ export default async function AdminAnalyticsPage() { @@ -457,7 +491,10 @@ export default async function AdminAnalyticsPage() { {t("userGrowth.description")} - + @@ -503,7 +540,10 @@ export default async function AdminAnalyticsPage() { - + diff --git a/src/components/admin/monthly-active-teams-chart.tsx b/src/components/admin/monthly-active-teams-chart.tsx index 80b8fcd5a..8655c11f1 100644 --- a/src/components/admin/monthly-active-teams-chart.tsx +++ b/src/components/admin/monthly-active-teams-chart.tsx @@ -5,6 +5,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; type ChartConfig = { @@ -23,19 +24,21 @@ export type MonthlyActiveTeamsData = { }; type MonthlyActiveTeamsChartProps = { - data: MonthlyActiveTeamsData[]; + twelveMonth: MonthlyActiveTeamsData[]; + historical: MonthlyActiveTeamsData[]; }; -export function MonthlyActiveTeamsChart({ - data, -}: MonthlyActiveTeamsChartProps) { - const chartConfig: ChartConfig = { - activeTeams: { - label: "Active Teams", - color: "var(--chart-4)", - }, - }; +const chartConfig: ChartConfig = { + activeTeams: { + label: "Active Teams", + color: "var(--chart-4)", + }, +}; +function renderChart( + data: MonthlyActiveTeamsData[], + opts: { shortTicks: boolean } +) { return ( value.slice(0, 3)} + tickFormatter={(value: string) => + opts.shortTicks ? value.slice(0, 3) : value + } + interval={opts.shortTicks ? 0 : "preserveStartEnd"} + minTickGap={opts.shortTicks ? 0 : 24} /> ); } + +export function MonthlyActiveTeamsChart({ + twelveMonth, + historical, +}: MonthlyActiveTeamsChartProps) { + return ( + + + Last 12 months + All time + + + {renderChart(twelveMonth, { shortTicks: true })} + + + {renderChart(historical, { shortTicks: false })} + + + ); +} diff --git a/src/components/admin/monthly-active-users-chart.tsx b/src/components/admin/monthly-active-users-chart.tsx index 45800198e..9f910ba02 100644 --- a/src/components/admin/monthly-active-users-chart.tsx +++ b/src/components/admin/monthly-active-users-chart.tsx @@ -5,6 +5,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; type ChartConfig = { @@ -23,19 +24,21 @@ export type MonthlyActiveUsersData = { }; type MonthlyActiveUsersChartProps = { - data: MonthlyActiveUsersData[]; + twelveMonth: MonthlyActiveUsersData[]; + historical: MonthlyActiveUsersData[]; }; -export function MonthlyActiveUsersChart({ - data, -}: MonthlyActiveUsersChartProps) { - const chartConfig: ChartConfig = { - activeUsers: { - label: "Active Users", - color: "var(--chart-2)", - }, - }; +const chartConfig: ChartConfig = { + activeUsers: { + label: "Active Users", + color: "var(--chart-2)", + }, +}; +function renderChart( + data: MonthlyActiveUsersData[], + opts: { shortTicks: boolean } +) { return ( value.slice(0, 3)} + tickFormatter={(value: string) => + opts.shortTicks ? value.slice(0, 3) : value + } + interval={opts.shortTicks ? 0 : "preserveStartEnd"} + minTickGap={opts.shortTicks ? 0 : 24} /> ); } + +export function MonthlyActiveUsersChart({ + twelveMonth, + historical, +}: MonthlyActiveUsersChartProps) { + return ( + + + Last 12 months + All time + + + {renderChart(twelveMonth, { shortTicks: true })} + + + {renderChart(historical, { shortTicks: false })} + + + ); +} diff --git a/src/components/admin/monthly-user-chart.tsx b/src/components/admin/monthly-user-chart.tsx index ae677d2a2..11135f31b 100644 --- a/src/components/admin/monthly-user-chart.tsx +++ b/src/components/admin/monthly-user-chart.tsx @@ -5,6 +5,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; type ChartConfig = { @@ -23,17 +24,18 @@ type MonthlyUserData = { }; type MonthlyUserChartProps = { - data: MonthlyUserData[]; + twelveMonth: MonthlyUserData[]; + historical: MonthlyUserData[]; }; -export function MonthlyUserChart({ data }: MonthlyUserChartProps) { - const chartConfig: ChartConfig = { - users: { - label: "Users", - color: "var(--chart-1)", - }, - }; +const chartConfig: ChartConfig = { + users: { + label: "Users", + color: "var(--chart-1)", + }, +}; +function renderChart(data: MonthlyUserData[], opts: { shortTicks: boolean }) { return ( @@ -43,7 +45,11 @@ export function MonthlyUserChart({ data }: MonthlyUserChartProps) { tickLine={false} tickMargin={10} axisLine={false} - tickFormatter={(value: string) => value.slice(0, 3)} + tickFormatter={(value: string) => + opts.shortTicks ? value.slice(0, 3) : value + } + interval={opts.shortTicks ? 0 : "preserveStartEnd"} + minTickGap={opts.shortTicks ? 0 : 24} /> } /> @@ -51,3 +57,23 @@ export function MonthlyUserChart({ data }: MonthlyUserChartProps) { ); } + +export function MonthlyUserChart({ + twelveMonth, + historical, +}: MonthlyUserChartProps) { + return ( + + + Last 12 months + All time + + + {renderChart(twelveMonth, { shortTicks: true })} + + + {renderChart(historical, { shortTicks: false })} + + + ); +} diff --git a/src/components/admin/team-creation-chart.tsx b/src/components/admin/team-creation-chart.tsx index 3c7687d30..fd500a105 100644 --- a/src/components/admin/team-creation-chart.tsx +++ b/src/components/admin/team-creation-chart.tsx @@ -5,6 +5,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; type ChartConfig = { @@ -23,17 +24,18 @@ type TeamCreationData = { }; type TeamCreationChartProps = { - data: TeamCreationData[]; + twelveMonth: TeamCreationData[]; + historical: TeamCreationData[]; }; -export function TeamCreationChart({ data }: TeamCreationChartProps) { - const chartConfig: ChartConfig = { - teams: { - label: "Teams Created", - color: "var(--chart-3)", - }, - }; +const chartConfig: ChartConfig = { + teams: { + label: "Teams Created", + color: "var(--chart-3)", + }, +}; +function renderChart(data: TeamCreationData[], opts: { shortTicks: boolean }) { return ( @@ -43,7 +45,11 @@ export function TeamCreationChart({ data }: TeamCreationChartProps) { tickLine={false} tickMargin={10} axisLine={false} - tickFormatter={(value: string) => value.slice(0, 3)} + tickFormatter={(value: string) => + opts.shortTicks ? value.slice(0, 3) : value + } + interval={opts.shortTicks ? 0 : "preserveStartEnd"} + minTickGap={opts.shortTicks ? 0 : 24} /> } /> @@ -51,3 +57,23 @@ export function TeamCreationChart({ data }: TeamCreationChartProps) { ); } + +export function TeamCreationChart({ + twelveMonth, + historical, +}: TeamCreationChartProps) { + return ( + + + Last 12 months + All time + + + {renderChart(twelveMonth, { shortTicks: true })} + + + {renderChart(historical, { shortTicks: false })} + + + ); +} From f5130cbe44f3c9c952b459ce7fd9e0da27241ef2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:10:25 -0400 Subject: [PATCH 0004/1107] Add credits schema and chat pricing lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation for AI chat pay-as-you-go. Adds UserCredits (balance, auto-refill config, saved Stripe payment method) and CreditTransaction (signed ledger with stripeEventId idempotency) models, plus a pricing helper that charges per input/output token at GPT-5.4 rates with a 10% markup, rounded up to the nearest cent. No migration applied — a follow-up will run prisma migrate against the DB. --- prisma/schema.prisma | 40 +++++++++++++++++++++++++++++++++++++++ src/lib/chat-pricing.ts | 36 +++++++++++++++++++++++++++++++++++ test/chat-pricing.test.ts | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/lib/chat-pricing.ts create mode 100644 test/chat-pricing.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca228dbe2..c5d31b172 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,8 @@ model User { chatConversations ChatConversation[] chatReports ChatReport[] availabilityResponses AvailabilityResponse[] + credits UserCredits? + creditTransactions CreditTransaction[] @@index([id, email]) } @@ -1480,3 +1482,41 @@ model AvailabilityResponse { @@index([scheduleId]) @@index([userId]) } + +model UserCredits { + userId String @id + balanceCents Int @default(0) + autoRefillEnabled Boolean @default(false) + autoRefillThresholdCents Int @default(200) + autoRefillAmountCents Int @default(1000) + stripePaymentMethodId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum CreditTransactionType { + TOPUP + AUTO_REFILL + CHARGE + REFUND + ADJUSTMENT +} + +model CreditTransaction { + id Int @id @default(autoincrement()) + userId String + type CreditTransactionType + amountCents Int + balanceAfterCents Int + description String + stripeEventId String? @unique + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) +} + diff --git a/src/lib/chat-pricing.ts b/src/lib/chat-pricing.ts new file mode 100644 index 000000000..b61d7a5d0 --- /dev/null +++ b/src/lib/chat-pricing.ts @@ -0,0 +1,36 @@ +export const CHAT_MODEL_PRICING = { + model: "openai/gpt-5.4", + inputPerMillionCents: 250, + outputPerMillionCents: 1500, + markupBps: 1000, +} as const; + +export const TOPUP_MIN_CENTS = 500; + +export const TOPUP_PRESETS_CENTS = [500, 1000, 2500, 5000] as const; + +export const DEFAULT_AUTO_REFILL_THRESHOLD_CENTS = 200; +export const DEFAULT_AUTO_REFILL_AMOUNT_CENTS = 1000; + +export type TokenUsage = { + inputTokens: number; + outputTokens: number; +}; + +export function calculateChargeCents({ + inputTokens, + outputTokens, +}: TokenUsage): number { + const baseCents = + (inputTokens * CHAT_MODEL_PRICING.inputPerMillionCents + + outputTokens * CHAT_MODEL_PRICING.outputPerMillionCents) / + 1_000_000; + + const withMarkup = baseCents * (1 + CHAT_MODEL_PRICING.markupBps / 10_000); + + return Math.max(1, Math.ceil(withMarkup)); +} + +export function formatCents(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} diff --git a/test/chat-pricing.test.ts b/test/chat-pricing.test.ts new file mode 100644 index 000000000..22b6e9a44 --- /dev/null +++ b/test/chat-pricing.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { CHAT_MODEL_PRICING, calculateChargeCents } from "@/lib/chat-pricing"; + +describe("calculateChargeCents", () => { + test("rounds up to at least 1 cent for tiny usage", () => { + expect(calculateChargeCents({ inputTokens: 1, outputTokens: 1 })).toBe(1); + }); + + test("applies the configured markup on top of raw token costs", () => { + const inputTokens = 1_000_000; + const outputTokens = 1_000_000; + const raw = + CHAT_MODEL_PRICING.inputPerMillionCents + + CHAT_MODEL_PRICING.outputPerMillionCents; + const expected = Math.ceil( + raw * (1 + CHAT_MODEL_PRICING.markupBps / 10_000) + ); + expect(calculateChargeCents({ inputTokens, outputTokens })).toBe(expected); + }); + + test("charges input and output tokens at different rates", () => { + const inputOnly = calculateChargeCents({ + inputTokens: 1_000_000, + outputTokens: 0, + }); + const outputOnly = calculateChargeCents({ + inputTokens: 0, + outputTokens: 1_000_000, + }); + expect(outputOnly).toBeGreaterThan(inputOnly); + }); + + test("rounds fractional cents up, never down", () => { + expect( + calculateChargeCents({ inputTokens: 100, outputTokens: 100 }) + ).toBeGreaterThanOrEqual(1); + }); +}); From e32262f10335aa98d9d74fad50f7cb25cea08e5f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:13:57 -0400 Subject: [PATCH 0005/1107] Add credits lib with charge, credit, and auto-refill logic Atomic DB transactions on charge and credit, with a signed ledger entry per operation for auditability. Idempotent crediting via unique stripeEventId so webhook replays don't double-credit. Auto-refill fires off_session PaymentIntents exactly once per threshold crossing; the webhook remains the source of truth for the actual balance top-up. --- src/lib/chat-pricing.ts | 14 ++ src/lib/credits.ts | 279 ++++++++++++++++++++++++++++++++++++++++ test/credits.test.ts | 49 +++++++ 3 files changed, 342 insertions(+) create mode 100644 src/lib/credits.ts create mode 100644 test/credits.test.ts diff --git a/src/lib/chat-pricing.ts b/src/lib/chat-pricing.ts index b61d7a5d0..a4e355dfe 100644 --- a/src/lib/chat-pricing.ts +++ b/src/lib/chat-pricing.ts @@ -34,3 +34,17 @@ export function calculateChargeCents({ export function formatCents(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } + +export function shouldTriggerAutoRefill(input: { + enabled: boolean; + hasPaymentMethod: boolean; + beforeCents: number; + afterCents: number; + thresholdCents: number; +}): boolean { + if (!input.enabled || !input.hasPaymentMethod) return false; + return ( + input.beforeCents >= input.thresholdCents && + input.afterCents < input.thresholdCents + ); +} diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 000000000..6f0143a99 --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,279 @@ +import "server-only"; + +import { + DEFAULT_AUTO_REFILL_AMOUNT_CENTS, + DEFAULT_AUTO_REFILL_THRESHOLD_CENTS, + shouldTriggerAutoRefill, +} from "@/lib/chat-pricing"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { stripe } from "@/lib/stripe"; +import type { + CreditTransactionType, + Prisma, + UserCredits, +} from "@prisma/client"; + +export type UserCreditsSnapshot = UserCredits; + +export async function getOrInitUserCredits( + userId: string +): Promise { + return prisma.userCredits.upsert({ + where: { userId }, + create: { + userId, + balanceCents: 0, + autoRefillEnabled: false, + autoRefillThresholdCents: DEFAULT_AUTO_REFILL_THRESHOLD_CENTS, + autoRefillAmountCents: DEFAULT_AUTO_REFILL_AMOUNT_CENTS, + }, + update: {}, + }); +} + +export async function getUserBalance(userId: string): Promise { + const credits = await prisma.userCredits.findUnique({ + where: { userId }, + select: { balanceCents: true }, + }); + return credits?.balanceCents ?? 0; +} + +type CreditArgs = { + amountCents: number; + type: Extract< + CreditTransactionType, + "TOPUP" | "AUTO_REFILL" | "REFUND" | "ADJUSTMENT" + >; + description: string; + stripeEventId?: string; + metadata?: Prisma.InputJsonValue; +}; + +export type CreditResult = + | { ok: true; balanceAfterCents: number; transactionId: number } + | { ok: false; reason: "duplicate" }; + +export async function creditUser( + userId: string, + args: CreditArgs +): Promise { + if (args.amountCents <= 0) { + throw new Error("credit amount must be positive"); + } + + try { + return await prisma.$transaction(async (tx) => { + const updated = await tx.userCredits.upsert({ + where: { userId }, + create: { + userId, + balanceCents: args.amountCents, + autoRefillThresholdCents: DEFAULT_AUTO_REFILL_THRESHOLD_CENTS, + autoRefillAmountCents: DEFAULT_AUTO_REFILL_AMOUNT_CENTS, + }, + update: { balanceCents: { increment: args.amountCents } }, + select: { balanceCents: true }, + }); + + const txn = await tx.creditTransaction.create({ + data: { + userId, + type: args.type, + amountCents: args.amountCents, + balanceAfterCents: updated.balanceCents, + description: args.description, + stripeEventId: args.stripeEventId, + metadata: args.metadata, + }, + select: { id: true }, + }); + + return { + ok: true as const, + balanceAfterCents: updated.balanceCents, + transactionId: txn.id, + }; + }); + } catch (error) { + if (isUniqueConstraintOn(error, "stripeEventId")) { + return { ok: false, reason: "duplicate" }; + } + throw error; + } +} + +type ChargeArgs = { + amountCents: number; + description: string; + metadata?: Prisma.InputJsonValue; +}; + +export type ChargeResult = { + balanceAfterCents: number; + transactionId: number; + autoRefillTriggered: boolean; +}; + +export async function chargeUser( + userId: string, + args: ChargeArgs +): Promise { + if (args.amountCents <= 0) { + throw new Error("charge amount must be positive"); + } + + const result = await prisma.$transaction(async (tx) => { + const before = await tx.userCredits.upsert({ + where: { userId }, + create: { + userId, + balanceCents: 0, + autoRefillThresholdCents: DEFAULT_AUTO_REFILL_THRESHOLD_CENTS, + autoRefillAmountCents: DEFAULT_AUTO_REFILL_AMOUNT_CENTS, + }, + update: {}, + }); + + const balanceAfterCents = before.balanceCents - args.amountCents; + + await tx.userCredits.update({ + where: { userId }, + data: { balanceCents: balanceAfterCents }, + }); + + const txn = await tx.creditTransaction.create({ + data: { + userId, + type: "CHARGE", + amountCents: -args.amountCents, + balanceAfterCents, + description: args.description, + metadata: args.metadata, + }, + select: { id: true }, + }); + + return { + balanceAfterCents, + transactionId: txn.id, + autoRefillTriggered: shouldTriggerAutoRefill({ + enabled: before.autoRefillEnabled, + hasPaymentMethod: !!before.stripePaymentMethodId, + beforeCents: before.balanceCents, + afterCents: balanceAfterCents, + thresholdCents: before.autoRefillThresholdCents, + }), + }; + }); + + return result; +} + +export async function attemptAutoRefill( + userId: string +): Promise< + | { ok: true; paymentIntentId: string } + | { ok: false; reason: "no_method" | "disabled" | "stripe_error" | "no_user" } +> { + const [credits, user] = await Promise.all([ + prisma.userCredits.findUnique({ where: { userId } }), + prisma.user.findUnique({ + where: { id: userId }, + select: { stripeId: true }, + }), + ]); + + if (!credits) return { ok: false, reason: "no_user" }; + if (!credits.autoRefillEnabled) return { ok: false, reason: "disabled" }; + if (!credits.stripePaymentMethodId || !user?.stripeId) { + return { ok: false, reason: "no_method" }; + } + + try { + const paymentIntent = await stripe.paymentIntents.create({ + amount: credits.autoRefillAmountCents, + currency: "usd", + customer: user.stripeId, + payment_method: credits.stripePaymentMethodId, + confirm: true, + off_session: true, + automatic_payment_methods: { enabled: true, allow_redirects: "never" }, + metadata: { + type: "ai_chat_auto_refill", + userId, + }, + }); + return { ok: true, paymentIntentId: paymentIntent.id }; + } catch (error) { + Logger.error("auto-refill payment failed", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + return { ok: false, reason: "stripe_error" }; + } +} + +export async function setAutoRefillConfig( + userId: string, + config: { + enabled?: boolean; + thresholdCents?: number; + amountCents?: number; + } +): Promise { + return prisma.userCredits.upsert({ + where: { userId }, + create: { + userId, + balanceCents: 0, + autoRefillEnabled: config.enabled ?? false, + autoRefillThresholdCents: + config.thresholdCents ?? DEFAULT_AUTO_REFILL_THRESHOLD_CENTS, + autoRefillAmountCents: + config.amountCents ?? DEFAULT_AUTO_REFILL_AMOUNT_CENTS, + }, + update: { + ...(config.enabled !== undefined && { + autoRefillEnabled: config.enabled, + }), + ...(config.thresholdCents !== undefined && { + autoRefillThresholdCents: config.thresholdCents, + }), + ...(config.amountCents !== undefined && { + autoRefillAmountCents: config.amountCents, + }), + }, + }); +} + +export async function saveDefaultPaymentMethod( + userId: string, + stripePaymentMethodId: string +): Promise { + await prisma.userCredits.upsert({ + where: { userId }, + create: { + userId, + balanceCents: 0, + stripePaymentMethodId, + }, + update: { stripePaymentMethodId }, + }); +} + +function isUniqueConstraintOn(error: unknown, field: string): boolean { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + const target = (error as { meta?: { target?: string[] | string } }).meta + ?.target; + if (Array.isArray(target)) return target.includes(field); + if (typeof target === "string") return target.includes(field); + } + return false; +} diff --git a/test/credits.test.ts b/test/credits.test.ts new file mode 100644 index 000000000..635b111a9 --- /dev/null +++ b/test/credits.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { shouldTriggerAutoRefill } from "@/lib/chat-pricing"; + +describe("shouldTriggerAutoRefill", () => { + const base = { + enabled: true, + hasPaymentMethod: true, + beforeCents: 300, + afterCents: 150, + thresholdCents: 200, + }; + + test("fires exactly when the charge crosses the threshold downward", () => { + expect(shouldTriggerAutoRefill(base)).toBe(true); + }); + + test("does not fire if the balance was already below threshold before the charge", () => { + expect( + shouldTriggerAutoRefill({ ...base, beforeCents: 150, afterCents: 50 }) + ).toBe(false); + }); + + test("does not fire if the post-charge balance is still above threshold", () => { + expect( + shouldTriggerAutoRefill({ ...base, beforeCents: 500, afterCents: 400 }) + ).toBe(false); + }); + + test("does not fire when auto-refill is disabled", () => { + expect(shouldTriggerAutoRefill({ ...base, enabled: false })).toBe(false); + }); + + test("does not fire when no payment method is saved", () => { + expect(shouldTriggerAutoRefill({ ...base, hasPaymentMethod: false })).toBe( + false + ); + }); + + test("fires when the charge drops balance exactly to the threshold boundary", () => { + expect( + shouldTriggerAutoRefill({ + ...base, + beforeCents: 250, + afterCents: 199, + thresholdCents: 200, + }) + ).toBe(true); + }); +}); From f29867def42e68aefc0df407748b6e46f9507f0e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:15:08 -0400 Subject: [PATCH 0006/1107] Meter AI chat against credit balance Pre-checks balance before starting the stream and returns a 402 with { blocked, balanceCents, hasChats } so the client can render the read-only or empty-state experience. On stream finish, charges the user for the actual input/output tokens reported by the SDK via the credits lib, and fires auto-refill off-session when the charge crosses the user's configured threshold. --- src/app/api/chat/route.ts | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 13f8aa2bb..b0ccbb25a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,7 +6,10 @@ import { chatTelemetry } from "@/lib/ai/telemetry"; import { buildTools } from "@/lib/ai/tools"; import { auth } from "@/lib/auth"; import { flagsToBaggage, setRequestContext } from "@/lib/axiom/baggage"; +import { calculateChargeCents } from "@/lib/chat-pricing"; +import { attemptAutoRefill, chargeUser, getUserBalance } from "@/lib/credits"; import { resolveAllFlags, toFlagValues } from "@/lib/flags-helpers"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; import { kv } from "@vercel/kv"; @@ -16,6 +19,7 @@ import { streamText, type UIMessage, } from "ai"; +import { after } from "next/server"; import { unauthorized } from "next/navigation"; export const maxDuration = 60; @@ -43,6 +47,21 @@ export async function POST(req: Request) { }); } + const balanceCents = await getUserBalance(userData.id); + if (balanceCents <= 0) { + const chatCount = await prisma.chatConversation.count({ + where: { userId: userData.id }, + }); + return Response.json( + { + blocked: true, + balanceCents, + hasChats: chatCount > 0, + }, + { status: 402 } + ); + } + const { messages } = (await req.json()) as { messages: UIMessage[] }; const user = await prisma.user.findUnique({ @@ -81,6 +100,38 @@ export async function POST(req: Request) { metadata: { userId: userData.id }, integrations: [chatTelemetry(userData.id)], }, + onFinish: ({ usage }) => { + after(async () => { + const inputTokens = usage.inputTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + if (inputTokens === 0 && outputTokens === 0) return; + + const chargeCents = calculateChargeCents({ inputTokens, outputTokens }); + try { + const charge = await chargeUser(userData.id, { + amountCents: chargeCents, + description: `AI chat: ${inputTokens} in / ${outputTokens} out`, + metadata: { inputTokens, outputTokens, model: "openai/gpt-5.4" }, + }); + if (charge.autoRefillTriggered) { + const refill = await attemptAutoRefill(userData.id); + if (!refill.ok) { + Logger.warn("auto-refill did not fire", { + userId: userData.id, + reason: refill.reason, + }); + } + } + } catch (error) { + Logger.error("failed to charge chat usage", { + userId: userData.id, + inputTokens, + outputTokens, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, }); return result.toUIMessageStreamResponse(); From faa2a8846d5242c370b1137758bc64262e659f6f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:16:40 -0400 Subject: [PATCH 0007/1107] Handle credit top-ups and auto-refill payments in Stripe Adds createTopupCheckout for one-time payments that save the payment method off_session so subsequent auto-refills can run without user interaction. Teaches the Stripe webhook to recognize ai_chat_topup checkout sessions and ai_chat_auto_refill PaymentIntents, crediting the user and storing the saved payment method. Uses the Stripe event id as the ledger idempotency key so webhook replays don't double-credit. --- src/app/api/stripe/webhooks/route.ts | 96 ++++++++++++++++++++++++++++ src/lib/stripe.ts | 61 ++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/src/app/api/stripe/webhooks/route.ts b/src/app/api/stripe/webhooks/route.ts index 52c3652a1..e3d24494c 100644 --- a/src/app/api/stripe/webhooks/route.ts +++ b/src/app/api/stripe/webhooks/route.ts @@ -1,5 +1,6 @@ import { stripeWebhookCounter } from "@/lib/axiom/metrics"; import { handleSubscriptionEvent } from "@/lib/billing-plans"; +import { creditUser, saveDefaultPaymentMethod } from "@/lib/credits"; import { Logger } from "@/lib/logger"; import { stripe } from "@/lib/stripe"; import { track } from "@vercel/analytics/server"; @@ -12,6 +13,7 @@ const relevantEvents = new Set([ "customer.subscription.created", "customer.subscription.deleted", "customer.subscription.updated", + "payment_intent.succeeded", ]); export async function POST(req: Request) { @@ -65,6 +67,18 @@ export async function POST(req: Request) { checkoutSession.customer as string, true ); + } else if ( + checkoutSession.mode === "payment" && + checkoutSession.metadata?.type === "ai_chat_topup" + ) { + await handleTopupCheckoutCompleted(checkoutSession, event.id); + } + break; + } + case "payment_intent.succeeded": { + const paymentIntent = event.data.object; + if (paymentIntent.metadata?.type === "ai_chat_auto_refill") { + await handleAutoRefillSucceeded(paymentIntent, event.id); } break; } @@ -87,3 +101,85 @@ export async function POST(req: Request) { } return new Response(JSON.stringify({ received: true })); } + +async function handleTopupCheckoutCompleted( + session: Stripe.Checkout.Session, + eventId: string +) { + const userId = session.metadata?.userId; + const amountCents = session.amount_total ?? 0; + + if (!userId || amountCents <= 0) { + Logger.warn("topup checkout missing userId or amount", { + sessionId: session.id, + }); + return; + } + + const result = await creditUser(userId, { + amountCents, + type: "TOPUP", + description: `Top-up via Stripe Checkout (${session.id})`, + stripeEventId: eventId, + metadata: { sessionId: session.id }, + }); + + if (!result.ok) { + Logger.info("topup checkout event already processed", { + eventId, + userId, + }); + return; + } + + if (typeof session.payment_intent === "string") { + try { + const paymentIntent = await stripe.paymentIntents.retrieve( + session.payment_intent + ); + const paymentMethodId = + typeof paymentIntent.payment_method === "string" + ? paymentIntent.payment_method + : paymentIntent.payment_method?.id; + if (paymentMethodId) { + await saveDefaultPaymentMethod(userId, paymentMethodId); + } + } catch (error) { + Logger.warn("failed to save payment method from topup", { + userId, + sessionId: session.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +async function handleAutoRefillSucceeded( + paymentIntent: Stripe.PaymentIntent, + eventId: string +) { + const userId = paymentIntent.metadata?.userId; + const amountCents = paymentIntent.amount_received ?? paymentIntent.amount; + + if (!userId || amountCents <= 0) { + Logger.warn("auto-refill payment missing userId or amount", { + paymentIntentId: paymentIntent.id, + }); + return; + } + + const result = await creditUser(userId, { + amountCents, + type: "AUTO_REFILL", + description: `Auto-refill via Stripe PaymentIntent (${paymentIntent.id})`, + stripeEventId: eventId, + metadata: { paymentIntentId: paymentIntent.id }, + }); + + if (!result.ok) { + Logger.info("auto-refill event already processed", { + eventId, + userId, + }); + } +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index c2bc56f49..35bec4fb7 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -66,6 +66,67 @@ export async function createCheckout( return checkoutSession; } +export async function createTopupCheckout( + session: Session | null, + amountCents: number +) { + const baseUrl = + process.env.NODE_ENV === "production" + ? "https://parsertime.app" + : "http://localhost:3000"; + + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + + if (!user || !user.stripeId) { + throw new Error("Unauthorized"); + } + + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "payment", + customer: user.stripeId, + line_items: [ + { + quantity: 1, + price_data: { + currency: "usd", + unit_amount: amountCents, + product_data: { + name: "Parsertime AI Chat credits", + description: "Credits for pay-as-you-go AI analyst usage.", + }, + }, + }, + ], + payment_intent_data: { + setup_future_usage: "off_session", + metadata: { + type: "ai_chat_topup", + userId: user.id, + amountCents: String(amountCents), + }, + }, + metadata: { + type: "ai_chat_topup", + userId: user.id, + amountCents: String(amountCents), + }, + success_url: `${baseUrl}/chat?topup=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/chat?topup=cancel`, + }); + + if (!checkoutSession.url) { + throw new Error("Error creating topup checkout session"); + } + + return checkoutSession; +} + export async function getCustomerPortalUrl(user: User) { const baseUrl = process.env.NODE_ENV === "production" From 165070a9e855b66bb7a4ffcfa1a88790d6eddb8a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:18:52 -0400 Subject: [PATCH 0008/1107] Expose credit balance, top-up, transactions, and auto-refill APIs GET /api/credits/balance returns the current balance plus auto-refill config and whether a saved payment method is on file. POST /api/credits/topup validates against the $5 minimum and returns a Stripe Checkout URL the client redirects to. PATCH /api/credits/auto-refill updates any subset of the auto-refill fields with zod validation. GET /api/credits/transactions lists the user's recent ledger entries for the credits card UI. --- src/app/api/credits/auto-refill/route.ts | 48 +++++++++++++++++++++++ src/app/api/credits/balance/route.ts | 26 ++++++++++++ src/app/api/credits/topup/route.ts | 48 +++++++++++++++++++++++ src/app/api/credits/transactions/route.ts | 41 +++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/app/api/credits/auto-refill/route.ts create mode 100644 src/app/api/credits/balance/route.ts create mode 100644 src/app/api/credits/topup/route.ts create mode 100644 src/app/api/credits/transactions/route.ts diff --git a/src/app/api/credits/auto-refill/route.ts b/src/app/api/credits/auto-refill/route.ts new file mode 100644 index 000000000..33f707e45 --- /dev/null +++ b/src/app/api/credits/auto-refill/route.ts @@ -0,0 +1,48 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { TOPUP_MIN_CENTS } from "@/lib/chat-pricing"; +import { setAutoRefillConfig } from "@/lib/credits"; +import { Effect } from "effect"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { z } from "zod"; + +const configSchema = z + .object({ + enabled: z.boolean().optional(), + thresholdCents: z.number().int().min(0).max(10_000).optional(), + amountCents: z.number().int().min(TOPUP_MIN_CENTS).max(10_000).optional(), + }) + .refine( + (data) => + data.enabled !== undefined || + data.thresholdCents !== undefined || + data.amountCents !== undefined, + { message: "Provide at least one field to update." } + ); + +export async function PATCH(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const parsed = configSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 } + ); + } + + const updated = await setAutoRefillConfig(user.id, parsed.data); + return Response.json({ + autoRefillEnabled: updated.autoRefillEnabled, + autoRefillThresholdCents: updated.autoRefillThresholdCents, + autoRefillAmountCents: updated.autoRefillAmountCents, + }); +} diff --git a/src/app/api/credits/balance/route.ts b/src/app/api/credits/balance/route.ts new file mode 100644 index 000000000..6d0c4c49b --- /dev/null +++ b/src/app/api/credits/balance/route.ts @@ -0,0 +1,26 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { getOrInitUserCredits } from "@/lib/credits"; +import { Effect } from "effect"; +import { unauthorized } from "next/navigation"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const credits = await getOrInitUserCredits(user.id); + + return Response.json({ + balanceCents: credits.balanceCents, + autoRefillEnabled: credits.autoRefillEnabled, + autoRefillThresholdCents: credits.autoRefillThresholdCents, + autoRefillAmountCents: credits.autoRefillAmountCents, + hasPaymentMethod: credits.stripePaymentMethodId !== null, + }); +} diff --git a/src/app/api/credits/topup/route.ts b/src/app/api/credits/topup/route.ts new file mode 100644 index 000000000..d8ad098db --- /dev/null +++ b/src/app/api/credits/topup/route.ts @@ -0,0 +1,48 @@ +import { TOPUP_MIN_CENTS } from "@/lib/chat-pricing"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import { createTopupCheckout } from "@/lib/stripe"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { z } from "zod"; + +const topupSchema = z.object({ + amountCents: z + .number() + .int() + .min( + TOPUP_MIN_CENTS, + `Minimum top-up is $${(TOPUP_MIN_CENTS / 100).toFixed(2)}.` + ) + .max(1_000_00, "Top-up cannot exceed $1,000.00."), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const parsed = topupSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message ?? "Invalid amount" }, + { status: 400 } + ); + } + + try { + const checkoutSession = await createTopupCheckout( + session, + parsed.data.amountCents + ); + return Response.json({ url: checkoutSession.url }); + } catch (error) { + Logger.error("failed to create topup checkout session", { + userEmail: session.user.email, + error: error instanceof Error ? error.message : String(error), + }); + return Response.json( + { error: "Failed to create checkout session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/credits/transactions/route.ts b/src/app/api/credits/transactions/route.ts new file mode 100644 index 000000000..757d56c48 --- /dev/null +++ b/src/app/api/credits/transactions/route.ts @@ -0,0 +1,41 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { Effect } from "effect"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const requested = Number(request.nextUrl.searchParams.get("limit")); + const limit = Number.isFinite(requested) + ? Math.min(Math.max(1, Math.trunc(requested)), MAX_LIMIT) + : DEFAULT_LIMIT; + + const transactions = await prisma.creditTransaction.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + type: true, + amountCents: true, + balanceAfterCents: true, + description: true, + createdAt: true, + }, + }); + + return Response.json({ transactions }); +} From 79391e04f3b5978baf0e9360dd4d9ccaa2331c83 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:24:19 -0400 Subject: [PATCH 0009/1107] Add credit balance UI across chat and billing Puts a balance chip at the top of the chat page that flips to a warning treatment at \$0 and opens a balance modal with top-up presets, a custom amount, auto-refill configuration, and the effective per-million-token rate the user is paying. Disables the chat input and replaces the starter suggestions with an Add-credits CTA when the balance hits zero, while leaving existing conversations visible in read-only mode. Adds a Credits card to /settings/billing that shows the balance, the last 10 ledger entries, and opens the same modal. --- src/app/settings/billing/page.tsx | 18 +- src/components/chat/balance-chip.tsx | 44 ++++ src/components/chat/balance-modal.tsx | 243 +++++++++++++++++++++++ src/components/chat/chat-interface.tsx | 79 ++++++-- src/components/settings/credits-card.tsx | 109 ++++++++++ src/hooks/use-credits.ts | 26 +++ src/lib/stripe.ts | 2 +- 7 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 src/components/chat/balance-chip.tsx create mode 100644 src/components/chat/balance-modal.tsx create mode 100644 src/components/settings/credits-card.tsx create mode 100644 src/hooks/use-credits.ts diff --git a/src/app/settings/billing/page.tsx b/src/app/settings/billing/page.tsx index 4dfdbf232..d214a380a 100644 --- a/src/app/settings/billing/page.tsx +++ b/src/app/settings/billing/page.tsx @@ -1,3 +1,4 @@ +import { CreditsCard } from "@/components/settings/credits-card"; import { UsageCard } from "@/components/settings/usage-card"; import { Separator } from "@/components/ui/separator"; import { Effect } from "effect"; @@ -57,13 +58,16 @@ export default async function SettingsBillingPage() {

{t("description")}

- +
+ + +
); } diff --git a/src/components/chat/balance-chip.tsx b/src/components/chat/balance-chip.tsx new file mode 100644 index 000000000..f136c5a99 --- /dev/null +++ b/src/components/chat/balance-chip.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { BalanceModal } from "@/components/chat/balance-modal"; +import { Button } from "@/components/ui/button"; +import { useCreditBalance } from "@/hooks/use-credits"; +import { formatCents } from "@/lib/chat-pricing"; +import { cn } from "@/lib/utils"; +import { AlertTriangleIcon, CoinsIcon } from "lucide-react"; +import { useState } from "react"; + +export function BalanceChip() { + const [open, setOpen] = useState(false); + const { data: balance, isLoading } = useCreditBalance(); + + const blocked = balance !== undefined && balance.balanceCents <= 0; + + return ( + <> + + + + ); +} diff --git a/src/components/chat/balance-modal.tsx b/src/components/chat/balance-modal.tsx new file mode 100644 index 000000000..7e991c655 --- /dev/null +++ b/src/components/chat/balance-modal.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useCreditBalance } from "@/hooks/use-credits"; +import { + CHAT_MODEL_PRICING, + TOPUP_MIN_CENTS, + TOPUP_PRESETS_CENTS, + formatCents, +} from "@/lib/chat-pricing"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function BalanceModal({ open, onOpenChange }: Props) { + const queryClient = useQueryClient(); + const { data: balance } = useCreditBalance({ enabled: open }); + + const [customAmount, setCustomAmount] = useState(""); + const [topupLoading, setTopupLoading] = useState(false); + const [autoRefillSaving, setAutoRefillSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function submitTopup(amountCents: number) { + if (amountCents < TOPUP_MIN_CENTS) { + setErrorMessage(`Minimum top-up is ${formatCents(TOPUP_MIN_CENTS)}.`); + return; + } + setErrorMessage(null); + setTopupLoading(true); + try { + const res = await fetch("/api/credits/topup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amountCents }), + }); + const data = (await res.json()) as { url?: string; error?: string }; + if (!res.ok || !data.url) { + setErrorMessage(data.error ?? "Failed to start checkout."); + return; + } + window.location.href = data.url; + } finally { + setTopupLoading(false); + } + } + + async function updateAutoRefill(patch: { + enabled?: boolean; + thresholdCents?: number; + amountCents?: number; + }) { + setAutoRefillSaving(true); + try { + const res = await fetch("/api/credits/auto-refill", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + setErrorMessage(data.error ?? "Failed to update auto-refill."); + return; + } + await queryClient.invalidateQueries({ queryKey: ["credits", "balance"] }); + } finally { + setAutoRefillSaving(false); + } + } + + const effectiveInputPerM = Math.round( + CHAT_MODEL_PRICING.inputPerMillionCents * + (1 + CHAT_MODEL_PRICING.markupBps / 10_000) + ); + const effectiveOutputPerM = Math.round( + CHAT_MODEL_PRICING.outputPerMillionCents * + (1 + CHAT_MODEL_PRICING.markupBps / 10_000) + ); + + return ( + + + + AI chat credits + + Pay-as-you-go balance for the AI analyst. Top up any time — $5 + minimum. + + + +
+ Current balance + + {balance ? formatCents(balance.balanceCents) : "—"} + +
+ +
+

Add credits

+
+ {TOPUP_PRESETS_CENTS.map((cents) => ( + + ))} +
+
+ + setCustomAmount(e.target.value)} + className="h-8 w-24" + /> + +
+ {errorMessage && ( +

{errorMessage}

+ )} +
+ +
+
+
+ +

+ Charge your saved card when the balance drops below the + threshold. +

+
+ + updateAutoRefill({ enabled: checked }) + } + /> +
+ {!balance?.hasPaymentMethod && ( +

+ Add credits once to save a payment method for auto-refill. +

+ )} + {balance?.hasPaymentMethod && ( +
+
+ + + updateAutoRefill({ + thresholdCents: Math.round(Number(e.target.value) * 100), + }) + } + className="h-8" + /> +
+
+ + + updateAutoRefill({ + amountCents: Math.round(Number(e.target.value) * 100), + }) + } + className="h-8" + /> +
+
+ )} +
+ +
+

Pricing

+

+ {formatCents(effectiveInputPerM)} per million input tokens,{" "} + {formatCents(effectiveOutputPerM)} per million output tokens. + Includes a 10% platform fee over the underlying model rate. +

+
+
+
+ ); +} diff --git a/src/components/chat/chat-interface.tsx b/src/components/chat/chat-interface.tsx index 6fff33afb..72d27292c 100644 --- a/src/components/chat/chat-interface.tsx +++ b/src/components/chat/chat-interface.tsx @@ -6,6 +6,8 @@ import { ConversationEmptyState, ConversationScrollButton, } from "@/components/ai-elements/conversation"; +import { BalanceChip } from "@/components/chat/balance-chip"; +import { BalanceModal } from "@/components/chat/balance-modal"; import { Message, MessageAction, @@ -30,6 +32,7 @@ import { } from "@/components/chat/tool-cards"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { useCreditBalance } from "@/hooks/use-credits"; import { useChat } from "@ai-sdk/react"; import { useQueryClient } from "@tanstack/react-query"; import type { ToolUIPart, UIMessage } from "ai"; @@ -85,6 +88,10 @@ export function ChatInterface({ const [editingMessageId, setEditingMessageId] = useState(null); const [editText, setEditText] = useState(""); const [copiedId, setCopiedId] = useState(null); + const [balanceModalOpen, setBalanceModalOpen] = useState(false); + + const { data: balance } = useCreditBalance(); + const blocked = balance !== undefined && balance.balanceCents <= 0; const isLoading = status === "streaming" || status === "submitted"; @@ -200,6 +207,9 @@ export function ChatInterface({ return (
+
+ +
{messages.length === 0 ? ( @@ -218,19 +228,31 @@ export function ChatInterface({ fight breakdowns, ability impact, and more.

-
- {SUGGESTIONS.map((s) => ( - - ))} -
+
+ ) : ( +
+ {SUGGESTIONS.map((s) => ( + + ))} +
+ )} ) : ( @@ -378,9 +400,29 @@ export function ChatInterface({
+ {blocked && ( +
+ + Your balance is empty. Add credits to continue chatting — your + past conversations stay visible in read-only mode. + + +
+ )}
{ e.preventDefault(); + if (blocked) { + setBalanceModalOpen(true); + return; + } handleSubmit(input); }} className="relative mx-auto max-w-3xl" @@ -389,8 +431,12 @@ export function ChatInterface({ value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Ask about your team's performance…" - disabled={isLoading} + placeholder={ + blocked + ? "Add credits to continue chatting…" + : "Ask about your team's performance…" + } + disabled={isLoading || blocked} rows={1} className="resize-none pr-12 text-sm" /> @@ -398,6 +444,7 @@ export function ChatInterface({ type={isLoading ? "button" : "submit"} size="icon" variant="ghost" + disabled={blocked && !isLoading} className="absolute right-1.5 bottom-1.5 size-8 active:scale-[0.96]" onClick={isLoading ? stop : undefined} aria-label={isLoading ? "Stop generating" : "Send message"} @@ -413,6 +460,10 @@ export function ChatInterface({
+ ); } diff --git a/src/components/settings/credits-card.tsx b/src/components/settings/credits-card.tsx new file mode 100644 index 000000000..f12d1ec93 --- /dev/null +++ b/src/components/settings/credits-card.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { BalanceModal } from "@/components/chat/balance-modal"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useCreditBalance } from "@/hooks/use-credits"; +import { formatCents } from "@/lib/chat-pricing"; +import type { CreditTransactionType } from "@prisma/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +type Transaction = { + id: number; + type: CreditTransactionType; + amountCents: number; + balanceAfterCents: number; + description: string; + createdAt: string; +}; + +async function fetchTransactions(): Promise { + const res = await fetch("/api/credits/transactions?limit=10", { + cache: "no-store", + }); + if (!res.ok) throw new Error(`transactions fetch failed: ${res.status}`); + const data = (await res.json()) as { transactions: Transaction[] }; + return data.transactions; +} + +export function CreditsCard() { + const [modalOpen, setModalOpen] = useState(false); + const { data: balance } = useCreditBalance(); + const { data: transactions } = useQuery({ + queryKey: ["credits", "transactions"], + queryFn: fetchTransactions, + }); + + return ( + <> + + +
+ AI chat credits + + Pay-as-you-go balance for the AI analyst. + +
+ +
+ +
+ Balance + + {balance ? formatCents(Math.max(0, balance.balanceCents)) : "—"} + + {balance?.autoRefillEnabled && ( + + · Auto-refill {formatCents(balance.autoRefillAmountCents)} at{" "} + {formatCents(balance.autoRefillThresholdCents)} + + )} +
+ +
+

Recent activity

+ {transactions && transactions.length > 0 ? ( +
    + {transactions.map((t) => ( +
  • + + {t.description} + + 0 + ? "text-emerald-600 tabular-nums dark:text-emerald-400" + : "tabular-nums" + } + > + {t.amountCents > 0 ? "+" : ""} + {formatCents(t.amountCents)} + +
  • + ))} +
+ ) : ( +

+ No transactions yet. Top up from the AI chat page or click + Manage. +

+ )} +
+
+
+ + + ); +} diff --git a/src/hooks/use-credits.ts b/src/hooks/use-credits.ts new file mode 100644 index 000000000..21bf14ba7 --- /dev/null +++ b/src/hooks/use-credits.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +export type CreditBalance = { + balanceCents: number; + autoRefillEnabled: boolean; + autoRefillThresholdCents: number; + autoRefillAmountCents: number; + hasPaymentMethod: boolean; +}; + +async function fetchBalance(): Promise { + const res = await fetch("/api/credits/balance", { cache: "no-store" }); + if (!res.ok) throw new Error(`balance fetch failed: ${res.status}`); + return (await res.json()) as CreditBalance; +} + +export function useCreditBalance(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ["credits", "balance"], + queryFn: fetchBalance, + enabled: options?.enabled ?? true, + staleTime: 5_000, + }); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 35bec4fb7..e7ada01b8 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -83,7 +83,7 @@ export async function createTopupCheckout( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) ); - if (!user || !user.stripeId) { + if (!user?.stripeId) { throw new Error("Unauthorized"); } From b791129d39a488036ed1d467410326e91e553d75 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 14:38:30 -0400 Subject: [PATCH 0010/1107] Harden credits against overdraft and duplicate auto-refills Preflight now rejects requests below a 25-cent safety buffer instead of a strict positive balance. Raises the floor above the calculated max cost of a typical tool-loop request so a single over-estimated charge cannot drive the balance meaningfully negative, and keeps the UI's blocked state aligned with the server so a low-balance chip and an input that would 402 are in sync. Auto-refill now passes a 60-second bucketed Stripe idempotency key. Concurrent charges from the same user that both cross the threshold collapse into a single PaymentIntent instead of creating duplicate off-session charges. The key shape is covered by unit tests so the dedup window stays deterministic. --- src/app/api/chat/route.ts | 8 +++-- src/components/chat/balance-chip.tsx | 5 +-- src/components/chat/chat-interface.tsx | 9 +++-- src/lib/chat-pricing.ts | 10 ++++++ src/lib/credits.ts | 30 +++++++++------- test/chat-pricing.test.ts | 50 +++++++++++++++++++++++++- 6 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b0ccbb25a..ea8b62f95 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,7 +6,10 @@ import { chatTelemetry } from "@/lib/ai/telemetry"; import { buildTools } from "@/lib/ai/tools"; import { auth } from "@/lib/auth"; import { flagsToBaggage, setRequestContext } from "@/lib/axiom/baggage"; -import { calculateChargeCents } from "@/lib/chat-pricing"; +import { + MIN_BALANCE_TO_CHAT_CENTS, + calculateChargeCents, +} from "@/lib/chat-pricing"; import { attemptAutoRefill, chargeUser, getUserBalance } from "@/lib/credits"; import { resolveAllFlags, toFlagValues } from "@/lib/flags-helpers"; import { Logger } from "@/lib/logger"; @@ -48,7 +51,7 @@ export async function POST(req: Request) { } const balanceCents = await getUserBalance(userData.id); - if (balanceCents <= 0) { + if (balanceCents < MIN_BALANCE_TO_CHAT_CENTS) { const chatCount = await prisma.chatConversation.count({ where: { userId: userData.id }, }); @@ -56,6 +59,7 @@ export async function POST(req: Request) { { blocked: true, balanceCents, + minimumBalanceCents: MIN_BALANCE_TO_CHAT_CENTS, hasChats: chatCount > 0, }, { status: 402 } diff --git a/src/components/chat/balance-chip.tsx b/src/components/chat/balance-chip.tsx index f136c5a99..0023a18c0 100644 --- a/src/components/chat/balance-chip.tsx +++ b/src/components/chat/balance-chip.tsx @@ -3,7 +3,7 @@ import { BalanceModal } from "@/components/chat/balance-modal"; import { Button } from "@/components/ui/button"; import { useCreditBalance } from "@/hooks/use-credits"; -import { formatCents } from "@/lib/chat-pricing"; +import { MIN_BALANCE_TO_CHAT_CENTS, formatCents } from "@/lib/chat-pricing"; import { cn } from "@/lib/utils"; import { AlertTriangleIcon, CoinsIcon } from "lucide-react"; import { useState } from "react"; @@ -12,7 +12,8 @@ export function BalanceChip() { const [open, setOpen] = useState(false); const { data: balance, isLoading } = useCreditBalance(); - const blocked = balance !== undefined && balance.balanceCents <= 0; + const blocked = + balance !== undefined && balance.balanceCents < MIN_BALANCE_TO_CHAT_CENTS; return ( <> diff --git a/src/components/chat/chat-interface.tsx b/src/components/chat/chat-interface.tsx index 72d27292c..2d56a287b 100644 --- a/src/components/chat/chat-interface.tsx +++ b/src/components/chat/chat-interface.tsx @@ -33,6 +33,7 @@ import { import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { useCreditBalance } from "@/hooks/use-credits"; +import { MIN_BALANCE_TO_CHAT_CENTS } from "@/lib/chat-pricing"; import { useChat } from "@ai-sdk/react"; import { useQueryClient } from "@tanstack/react-query"; import type { ToolUIPart, UIMessage } from "ai"; @@ -91,7 +92,8 @@ export function ChatInterface({ const [balanceModalOpen, setBalanceModalOpen] = useState(false); const { data: balance } = useCreditBalance(); - const blocked = balance !== undefined && balance.balanceCents <= 0; + const blocked = + balance !== undefined && balance.balanceCents < MIN_BALANCE_TO_CHAT_CENTS; const isLoading = status === "streaming" || status === "submitted"; @@ -403,8 +405,9 @@ export function ChatInterface({ {blocked && (
- Your balance is empty. Add credits to continue chatting — your - past conversations stay visible in read-only mode. + Your balance is too low to start a new message. Add credits to + continue chatting — your past conversations stay visible in + read-only mode.
+ + +
+ {subroleFilters.map((f) => { + const active = roleFilter === f.value; + return ( + + ); + })} +
+ +
+
+
#
+
Hero
+
Role
+
Playtime
+
Pick
+
Winrate
+
Sample
+
Δ 30d
+
+ + {heroes.length === 0 ? ( +
+ No heroes match the selected filter. +
+ ) : ( +
    + {heroes.map((hero, index) => ( + + ))} +
+ )} +
+ + ); +} + +function MapCommandItem({ + group, + selected, + displayName, + onSelect, +}: { + group: MapHeroTrendGroup; + selected: boolean; + displayName: string; + onSelect: () => void; +}) { + const kebab = toKebabCase(group.mapName); + return ( + + + {displayName} + {displayName} + + ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function FilterPills({ + filters, + value, + onChange, +}: { + filters: { value: RoleFilter; label: string }[]; + value: RoleFilter; + onChange: (v: RoleFilter) => void; +}) { + return ( +
+ {filters.map((f) => { + const active = value === f.value; + return ( + + ); + })} +
+ ); +} + +type HeroRowData = { + hero: string; + role: string; + subrole: SubroleName | null; + totalPlaytime: number; + pickRate: number; + wins: number; + losses: number; + winrate: number; + samples: number; + playtimeTrend: number; +}; + +function HeroRow({ + index, + hero, + name, +}: { + index: number; + hero: HeroRowData; + name: string; +}) { + const isTop = index === 0; + const rank = String(index + 1).padStart(2, "0"); + const games = hero.wins + hero.losses; + const trend = hero.playtimeTrend; + const trendUp = trend > 1; + const trendDown = trend < -1; + const trendFlat = !trendUp && !trendDown; + + return ( +
  • +
    + {rank} +
    + +
    + {name} +
    +
    + {name} +
    + {hero.subrole ? ( +
    + {SUBROLE_DISPLAY_NAMES[hero.subrole]} +
    + ) : null} +
    +
    + +
    + {hero.role} +
    + +
    + {formatPlaytime(hero.totalPlaytime)} +
    + +
    + {formatPercent(hero.pickRate)} +
    + +
    +
    + {games > 0 ? formatPercent(hero.winrate) : "—"} +
    + {games > 0 ? ( +
    + {hero.wins}W · {hero.losses}L +
    + ) : null} +
    + +
    + {hero.samples.toLocaleString("en-US")} +
    + +
    + + {trendUp ? "↑" : trendDown ? "↓" : "·"} + + + {Math.abs(trend) < 0.5 ? "—" : `${Math.round(Math.abs(trend))}%`} + +
    +
  • + ); +} From 92a5e1df08cc7a50a74f74e5858a0945fb54181d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 22:44:14 -0400 Subject: [PATCH 0025/1107] Add metrics docs --- docs/metrics.md | 1718 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1718 insertions(+) create mode 100644 docs/metrics.md diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 000000000..6e44103c6 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,1718 @@ +# Parsertime Metrics + +All metrics are emitted to the Axiom dataset `ptime-metrics`. Paste each MPL query into Axiom's metrics chart builder (Explore → new chart → paste into the editor). + +Chart-builder tips: +- Counters use `align to 5m using sum` — switch to `align using rate` for per-second rate charts. +- Duration histograms (`*_ms`) use `align to 5m using avg`. Use `p95` / `p99` via the builder's aggregator picker if you want tail latency. +- Replace `5m` with `$__interval` to let the dashboard auto-bucket by time range. + +## Contents + +Data-layer metrics (`src/data/`): + +- [Admin](#admin) +- [Comparison](#comparison) +- [Hero](#hero) +- [Intelligence](#intelligence) +- [Map](#map) +- [Player](#player) +- [Scouting](#scouting) +- [Scrim](#scrim) +- [Team](#team) +- [Tournament](#tournament) +- [Tournament Team](#tournament-team) +- [User](#user) + +Application metrics (`src/lib/axiom/metrics.ts`): + +- [Application](#application) + +## Admin + +Source: `src/data/admin/metrics.ts` + +### Unlabeled matches + +Successes (counter): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.duration_ms` | align to 5m using avg +``` + +### Match for labeling + +Successes (counter): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.duration_ms` | align to 5m using avg +``` + +### Admin cache + +Requests (counter): + +``` +`ptime-metrics`:`admin.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`admin.cache.miss` | align to 5m using sum +``` + +## Comparison + +Source: `src/data/comparison/metrics.ts` + +### Comparison stats + +Successes (counter): + +``` +`ptime-metrics`:`comparison.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.stats.query.duration_ms` | align to 5m using avg +``` + +### Available maps + +Successes (counter): + +``` +`ptime-metrics`:`comparison.available_maps.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.available_maps.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.available_maps.query.duration_ms` | align to 5m using avg +``` + +### Team players + +Successes (counter): + +``` +`ptime-metrics`:`comparison.team_players.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.team_players.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.team_players.query.duration_ms` | align to 5m using avg +``` + +### Trends + +Successes (counter): + +``` +`ptime-metrics`:`comparison.trends.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.trends.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.trends.duration_ms` | align to 5m using avg +``` + +### Comparison cache + +Requests (counter): + +``` +`ptime-metrics`:`comparison.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`comparison.cache.miss` | align to 5m using sum +``` + +## Hero + +Source: `src/data/hero/metrics.ts` + +### Hero stats + +Successes (counter): + +``` +`ptime-metrics`:`hero.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.stats.query.duration_ms` | align to 5m using avg +``` + +### Hero kills + +Successes (counter): + +``` +`ptime-metrics`:`hero.kills.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.kills.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.kills.query.duration_ms` | align to 5m using avg +``` + +### Hero deaths + +Successes (counter): + +``` +`ptime-metrics`:`hero.deaths.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.deaths.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.deaths.query.duration_ms` | align to 5m using avg +``` + +### Hero cache + +Requests (counter): + +``` +`ptime-metrics`:`hero.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`hero.cache.miss` | align to 5m using sum +``` + +## Intelligence + +Source: `src/data/intelligence/metrics.ts` + +### Hero ban intelligence + +Successes (counter): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.duration_ms` | align to 5m using avg +``` + +### Map intelligence + +Successes (counter): + +``` +`ptime-metrics`:`intelligence.map.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`intelligence.map.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`intelligence.map.query.duration_ms` | align to 5m using avg +``` + +### Intelligence cache + +Requests (counter): + +``` +`ptime-metrics`:`intelligence.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`intelligence.cache.miss` | align to 5m using sum +``` + +## Map + +Source: `src/data/map/metrics.ts` + +### Heatmap + +Successes (counter): + +``` +`ptime-metrics`:`map.heatmap.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.heatmap.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.heatmap.query.duration_ms` | align to 5m using avg +``` + +### Killfeed ult spans + +Successes (counter): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.duration_ms` | align to 5m using avg +``` + +### Killfeed calibration + +Successes (counter): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.duration_ms` | align to 5m using avg +``` + +### Replay data + +Successes (counter): + +``` +`ptime-metrics`:`map.replay.data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.replay.data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.replay.data.query.duration_ms` | align to 5m using avg +``` + +### Tempo + +Successes (counter): + +``` +`ptime-metrics`:`map.tempo.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.tempo.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.tempo.query.duration_ms` | align to 5m using avg +``` + +### Rotation death + +Successes (counter): + +``` +`ptime-metrics`:`map.rotation_death.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.rotation_death.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.rotation_death.query.duration_ms` | align to 5m using avg +``` + +### Map group (query) + +Successes (counter): + +``` +`ptime-metrics`:`map.group.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.group.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.group.query.duration_ms` | align to 5m using avg +``` + +### Map group (mutation) + +Successes (counter): + +``` +`ptime-metrics`:`map.group.mutation.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.group.mutation.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.group.mutation.duration_ms` | align to 5m using avg +``` + +### Map hero trends + +Successes (counter): + +``` +`ptime-metrics`:`map.hero_trends.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.hero_trends.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.hero_trends.query.duration_ms` | align to 5m using avg +``` + +### Map cache + +Requests (counter): + +``` +`ptime-metrics`:`map.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`map.cache.miss` | align to 5m using sum +``` + +## Player + +Source: `src/data/player/metrics.ts` + +### Most played heroes + +Successes (counter): + +``` +`ptime-metrics`:`player.most_played.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.most_played.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.most_played.query.duration_ms` | align to 5m using avg +``` + +### Player intelligence + +Successes (counter): + +``` +`ptime-metrics`:`player.intelligence.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.intelligence.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.intelligence.query.duration_ms` | align to 5m using avg +``` + +### Scouting players + +Successes (counter): + +``` +`ptime-metrics`:`player.scouting_players.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.scouting_players.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.scouting_players.query.duration_ms` | align to 5m using avg +``` + +### Player profile + +Successes (counter): + +``` +`ptime-metrics`:`player.profile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.profile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.profile.query.duration_ms` | align to 5m using avg +``` + +### Scouting analytics + +Successes (counter): + +``` +`ptime-metrics`:`player.scouting_analytics.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.scouting_analytics.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.scouting_analytics.query.duration_ms` | align to 5m using avg +``` + +### Player targets + +Successes (counter): + +``` +`ptime-metrics`:`player.targets.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.targets.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.targets.query.duration_ms` | align to 5m using avg +``` + +### Team targets + +Successes (counter): + +``` +`ptime-metrics`:`player.team_targets.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.team_targets.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.team_targets.query.duration_ms` | align to 5m using avg +``` + +### Recent scrim stats + +Successes (counter): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.duration_ms` | align to 5m using avg +``` + +### Player cache + +Requests (counter): + +``` +`ptime-metrics`:`player.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`player.cache.miss` | align to 5m using sum +``` + +## Scouting + +Source: `src/data/scouting/metrics.ts` + +### Scouting teams + +Successes (counter): + +``` +`ptime-metrics`:`scouting.teams.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.teams.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.teams.query.duration_ms` | align to 5m using avg +``` + +### Opponent match data + +Successes (counter): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.duration_ms` | align to 5m using avg +``` + +### Team profile + +Successes (counter): + +``` +`ptime-metrics`:`scouting.team_profile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.team_profile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.team_profile.query.duration_ms` | align to 5m using avg +``` + +### Strength ratings + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.duration_ms` | align to 5m using avg +``` + +### Strength rating + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_rating.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_rating.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_rating.query.duration_ms` | align to 5m using avg +``` + +### Strength percentile + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.duration_ms` | align to 5m using avg +``` + +### Scouting cache + +Requests (counter): + +``` +`ptime-metrics`:`scouting.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`scouting.cache.miss` | align to 5m using sum +``` + +## Scrim + +Source: `src/data/scrim/metrics.ts` + +### Get scrim + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_scrim.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_scrim.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_scrim.query.duration_ms` | align to 5m using avg +``` + +### User-viewable scrims + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.duration_ms` | align to 5m using avg +``` + +### Final round stats + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.duration_ms` | align to 5m using avg +``` + +### Final round stats (player) + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.duration_ms` | align to 5m using avg +``` + +### All stats for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.duration_ms` | align to 5m using avg +``` + +### All kills for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.duration_ms` | align to 5m using avg +``` + +### All deaths for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.duration_ms` | align to 5m using avg +``` + +### All map winrates for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.duration_ms` | align to 5m using avg +``` + +### Scrim overview + +Successes (counter): + +``` +`ptime-metrics`:`scrim.overview.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.overview.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.overview.query.duration_ms` | align to 5m using avg +``` + +### Opponent map results + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.duration_ms` | align to 5m using avg +``` + +### Opponent hero bans + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.duration_ms` | align to 5m using avg +``` + +### Opponent player stats + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.duration_ms` | align to 5m using avg +``` + +### Ability timing + +Successes (counter): + +``` +`ptime-metrics`:`scrim.ability_timing.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.ability_timing.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.ability_timing.query.duration_ms` | align to 5m using avg +``` + +### Fight timelines + +Successes (counter): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.duration_ms` | align to 5m using avg +``` + +### Map ability timing + +Successes (counter): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.duration_ms` | align to 5m using avg +``` + +### Scrim cache + +Requests (counter): + +``` +`ptime-metrics`:`scrim.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`scrim.cache.miss` | align to 5m using sum +``` + +## Team + +Source: `src/data/team/metrics.ts` + +### Team roster + +Successes (counter): + +``` +`ptime-metrics`:`team.roster.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.roster.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.roster.query.duration_ms` | align to 5m using avg +``` + +### Team base data + +Successes (counter): + +``` +`ptime-metrics`:`team.base_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.base_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.base_data.query.duration_ms` | align to 5m using avg +``` + +### Team extended data + +Successes (counter): + +``` +`ptime-metrics`:`team.extended_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.extended_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.extended_data.query.duration_ms` | align to 5m using avg +``` + +### Team cache + +Requests (counter): + +``` +`ptime-metrics`:`team.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`team.cache.miss` | align to 5m using sum +``` + +## Tournament + +Source: `src/data/tournament/metrics.ts` + +### Get tournament + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament.query.duration_ms` | align to 5m using avg +``` + +### User tournaments + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.duration_ms` | align to 5m using avg +``` + +### Tournament match + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.duration_ms` | align to 5m using avg +``` + +### Tournament bracket + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.duration_ms` | align to 5m using avg +``` + +### RR standings + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.duration_ms` | align to 5m using avg +``` + +### Broadcast data + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.duration_ms` | align to 5m using avg +``` + +### Tournament cache + +Requests (counter): + +``` +`ptime-metrics`:`tournament.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`tournament.cache.miss` | align to 5m using sum +``` + +### Broadcast cache + +Requests (counter): + +``` +`ptime-metrics`:`broadcast.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`broadcast.cache.miss` | align to 5m using sum +``` + +## Tournament Team + +Source: `src/data/tournament-team/metrics.ts` + +### Base data + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.base_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.base_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.base_data.query.duration_ms` | align to 5m using avg +``` + +### Roster + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.roster.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.roster.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.roster.query.duration_ms` | align to 5m using avg +``` + +### Extended data + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.duration_ms` | align to 5m using avg +``` + +### Stats + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.stats.query.duration_ms` | align to 5m using avg +``` + +### Tournament team cache + +Requests (counter): + +``` +`ptime-metrics`:`tournament_team.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`tournament_team.cache.miss` | align to 5m using sum +``` + +## User + +Source: `src/data/user/metrics.ts` + +### getUser + +Successes (counter): + +``` +`ptime-metrics`:`user.getUser.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getUser.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getUser.duration_ms` | align to 5m using avg +``` + +### getTeamsWithPerms + +Successes (counter): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.duration_ms` | align to 5m using avg +``` + +### getAppSettings + +Successes (counter): + +``` +`ptime-metrics`:`user.getAppSettings.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getAppSettings.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getAppSettings.duration_ms` | align to 5m using avg +``` + +### User cache + +Requests (counter): + +``` +`ptime-metrics`:`user.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`user.cache.miss` | align to 5m using sum +``` + +## Application + +Source: `src/lib/axiom/metrics.ts` + +Application-wide counters and latency histograms covering the core funnel, HTTP, database, AI chat, reliability, scrim pipeline, cron, billing, and bot notifications. + +### Core funnel + +**Sign-ins** — Counter: + +``` +`ptime-metrics`:`auth.signins` | align to 5m using sum +``` + +**New user registrations** — Counter: + +``` +`ptime-metrics`:`auth.new_users` | align to 5m using sum +``` + +**Teams created** — Counter: + +``` +`ptime-metrics`:`teams.created` | align to 5m using sum +``` + +**Team quota hits** — Counter: + +``` +`ptime-metrics`:`teams.quota_hits` | align to 5m using sum +``` + +**Scrims created** — Counter: + +``` +`ptime-metrics`:`scrims.created` | align to 5m using sum +``` + +**Maps added** — Counter: + +``` +`ptime-metrics`:`scrims.maps_added` | align to 5m using sum +``` + +**Maps removed** — Counter: + +``` +`ptime-metrics`:`scrims.maps_removed` | align to 5m using sum +``` + +### HTTP + +**HTTP requests** — Counter: + +``` +`ptime-metrics`:`http.requests` | align to 5m using sum +``` + +**HTTP errors (4xx/5xx)** — Counter: + +``` +`ptime-metrics`:`http.errors` | align to 5m using sum +``` + +**HTTP request duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`http.request_duration_ms` | align to 5m using avg +``` + +### AI chat + +**Chat requests** — Counter: + +``` +`ptime-metrics`:`ai.chat.requests` | align to 5m using sum +``` + +**Chat tokens consumed** — Counter: + +``` +`ptime-metrics`:`ai.chat.tokens` | align to 5m using sum +``` + +**Chat tool calls** — Counter: + +``` +`ptime-metrics`:`ai.chat.tool_calls` | align to 5m using sum +``` + +**Chat response duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`ai.chat.duration_ms` | align to 5m using avg +``` + +**Chat tool-call duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`ai.chat.tool_call_duration_ms` | align to 5m using avg +``` + +### Reliability + +**Rate-limit hits** — Counter: + +``` +`ptime-metrics`:`ratelimit.hits` | align to 5m using sum +``` + +### Scrim pipeline + +**Scrim parse duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`scrims.parse_duration_ms` | align to 5m using avg +``` + +**Map deletion duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`scrims.map_deletion_duration_ms` | align to 5m using avg +``` + +### Cron + +**Cron runs** — Counter: + +``` +`ptime-metrics`:`cron.runs` | align to 5m using sum +``` + +**Items deleted by cron** — Counter: + +``` +`ptime-metrics`:`cron.deleted_items` | align to 5m using sum +``` + +**Cron duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`cron.duration_ms` | align to 5m using avg +``` + +### Billing & bot + +**Stripe webhooks** — Counter: + +``` +`ptime-metrics`:`stripe.webhooks` | align to 5m using sum +``` + +**Bot notifications** — Counter: + +``` +`ptime-metrics`:`bot.notifications` | align to 5m using sum +``` + From 6fc2a8e59255bf367fe34a7ea4b0e7d89f7e508b Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 22:54:52 -0400 Subject: [PATCH 0026/1107] Add Sierra to heroes in en.json --- messages/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/en.json b/messages/en.json index ddb865383..12cc04d5b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -73,6 +73,7 @@ "reaper": "Reaper", "reinhardt": "Reinhardt", "roadhog": "Roadhog", + "sierra": "Sierra", "sigma": "Sigma", "sojourn": "Sojourn", "soldier76": "Soldier: 76", From de9a2a98ab2abb75c1c5d7ccb8133dfac9dafba0 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 23:03:11 -0400 Subject: [PATCH 0027/1107] Rename .impeccable.md to PRODUCT.md --- .impeccable.md => PRODUCT.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .impeccable.md => PRODUCT.md (100%) diff --git a/.impeccable.md b/PRODUCT.md similarity index 100% rename from .impeccable.md rename to PRODUCT.md From e0740e67e57a4d98bb89e85b08c2d8068e2e308d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 24 Apr 2026 23:03:11 -0400 Subject: [PATCH 0028/1107] Retune old landing page hero blobs to amber --- src/components/home/landing-page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/home/landing-page.tsx b/src/components/home/landing-page.tsx index e36e3d988..8923d17bc 100644 --- a/src/components/home/landing-page.tsx +++ b/src/components/home/landing-page.tsx @@ -257,7 +257,7 @@ export async function LandingPage() { aria-hidden="true" >