From c8cfd51d2e48662b446f41c73aef0139ee5d0ca2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 19:29:33 -0700 Subject: [PATCH 1/7] add dedicated usage page in sidebar --- .../src/billing/spendAnalysisFormat.test.ts | 27 ++- .../core/src/billing/spendAnalysisFormat.ts | 10 + packages/shared/src/analytics-events.ts | 15 +- packages/shared/src/flags.ts | 1 + .../ui/src/features/billing/UsageMeter.tsx | 40 ++++ .../src/features/billing/useSpendAnalysis.ts | 50 ---- .../settings/sections/PlanUsageSettings.tsx | 74 ++---- .../sidebar/components/SidebarNavSection.tsx | 19 +- .../sidebar/components/items/UsageItem.tsx | 19 ++ packages/ui/src/features/usage/UsageView.tsx | 159 +++++++++++++ .../components/SpendAnalysisSection.tsx} | 213 ++++++++---------- .../usage/components/WindowSelector.tsx | 30 +++ .../ui/src/features/usage/useSpendAnalysis.ts | 61 +++++ .../features/usage/useSpendAnalysisEnabled.ts | 6 + .../src/features/usage/useTrackUsageViewed.ts | 41 ++++ packages/ui/src/router/navigationBridge.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 21 ++ packages/ui/src/router/routes/usage.tsx | 6 + packages/ui/src/router/useAppView.ts | 3 + 19 files changed, 576 insertions(+), 223 deletions(-) create mode 100644 packages/ui/src/features/billing/UsageMeter.tsx delete mode 100644 packages/ui/src/features/billing/useSpendAnalysis.ts create mode 100644 packages/ui/src/features/sidebar/components/items/UsageItem.tsx create mode 100644 packages/ui/src/features/usage/UsageView.tsx rename packages/ui/src/features/{billing/TokenSpendAnalysisBanner.tsx => usage/components/SpendAnalysisSection.tsx} (64%) create mode 100644 packages/ui/src/features/usage/components/WindowSelector.tsx create mode 100644 packages/ui/src/features/usage/useSpendAnalysis.ts create mode 100644 packages/ui/src/features/usage/useSpendAnalysisEnabled.ts create mode 100644 packages/ui/src/features/usage/useTrackUsageViewed.ts create mode 100644 packages/ui/src/router/routes/usage.tsx diff --git a/packages/core/src/billing/spendAnalysisFormat.test.ts b/packages/core/src/billing/spendAnalysisFormat.test.ts index 2844b75feb..ea531eb921 100644 --- a/packages/core/src/billing/spendAnalysisFormat.test.ts +++ b/packages/core/src/billing/spendAnalysisFormat.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatTokens } from "./spendAnalysisFormat"; +import { + formatTokens, + type SpendAnalysisWindow, + windowToDateFrom, + windowToDays, +} from "./spendAnalysisFormat"; describe("formatTokens", () => { it.each([ @@ -15,3 +20,23 @@ describe("formatTokens", () => { expect(formatTokens(input)).toBe(expected); }); }); + +describe("windowToDateFrom", () => { + it.each<[SpendAnalysisWindow, string]>([ + ["7d", "-7d"], + ["30d", "-30d"], + ["90d", "-90d"], + ])("maps %s to %s", (window, expected) => { + expect(windowToDateFrom(window)).toBe(expected); + }); +}); + +describe("windowToDays", () => { + it.each<[SpendAnalysisWindow, number]>([ + ["7d", 7], + ["30d", 30], + ["90d", 90], + ])("maps %s to %d", (window, expected) => { + expect(windowToDays(window)).toBe(expected); + }); +}); diff --git a/packages/core/src/billing/spendAnalysisFormat.ts b/packages/core/src/billing/spendAnalysisFormat.ts index cafcc3c8b6..fcbb2c964f 100644 --- a/packages/core/src/billing/spendAnalysisFormat.ts +++ b/packages/core/src/billing/spendAnalysisFormat.ts @@ -12,6 +12,16 @@ export function formatTokens(n: number): string { return n.toString(); } +export type SpendAnalysisWindow = "7d" | "30d" | "90d"; + +export function windowToDateFrom(window: SpendAnalysisWindow): string { + return `-${window}`; +} + +export function windowToDays(window: SpendAnalysisWindow): number { + return Number.parseInt(window, 10); +} + export function windowDays(fromIso: string, toIso: string): number { const fromMs = new Date(fromIso).getTime(); const toMs = new Date(toIso).getTime(); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index ddf9db77c4..e7d6173f69 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -626,6 +626,15 @@ export interface InboxReportScrolledProperties { time_since_open_ms: number; } +export interface UsageViewedProperties { + is_pro: boolean; + /** Monthly bucket percent (0-100), null when usage is unavailable. */ + sustained_used_percent: number | null; + /** Daily bucket percent (0-100), null when usage is unavailable. */ + burst_used_percent: number | null; + spend_analysis_window_days: number; +} + export interface SpendAnalysisTaskOpenedProperties { /** Total LLM spend in USD across all products for the analysed window. */ total_cost_usd: number; @@ -1129,7 +1138,8 @@ export const ANALYTICS_EVENTS = { SCOUT_CHAT_STARTED: "Scout chat started", SCOUT_ACTION: "Scout action", - // Spend analysis events + // Usage and spend analysis events + USAGE_VIEWED: "Usage viewed", SPEND_ANALYSIS_TASK_OPENED: "Spend analysis task opened", // Prompt history events @@ -1281,7 +1291,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SCOUT_CHAT_STARTED]: ScoutChatStartedProperties; [ANALYTICS_EVENTS.SCOUT_ACTION]: ScoutActionProperties; - // Spend analysis events + // Usage and spend analysis events + [ANALYTICS_EVENTS.USAGE_VIEWED]: UsageViewedProperties; [ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED]: SpendAnalysisTaskOpenedProperties; // Prompt history events diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index a74f368588..1a36acf9fa 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,4 +1,5 @@ export const BILLING_FLAG = "posthog-code-billing"; +export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx new file mode 100644 index 0000000000..65bb71ddd9 --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -0,0 +1,40 @@ +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +import type { UsageBucket } from "@posthog/core/usage/schemas"; +import { Flex, Progress, Text } from "@radix-ui/themes"; + +interface UsageMeterProps { + label: string; + bucket: UsageBucket; + color?: "red"; +} + +export function UsageMeter({ label, bucket, color }: UsageMeterProps) { + const percentage = bucket.used_percent; + + const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + + return ( + + + {label} + {percentage.toFixed(2)}% + + + + {`${bucket.exceeded ? "Limit exceeded. " : ""}${formatResetTime(bucket.reset_at)}`} + + + ); +} diff --git a/packages/ui/src/features/billing/useSpendAnalysis.ts b/packages/ui/src/features/billing/useSpendAnalysis.ts deleted file mode 100644 index 4aad13e7d1..0000000000 --- a/packages/ui/src/features/billing/useSpendAnalysis.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { SpendAnalysisResponse } from "@posthog/api-client/spend-analysis"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; -import { logger } from "@posthog/ui/shell/logger"; -import { useCallback, useState } from "react"; - -const log = logger.scope("spend-analysis"); - -interface RunOptions { - dateFrom?: string; - dateTo?: string; - product?: string; -} - -interface UseSpendAnalysisReturn { - data: SpendAnalysisResponse | null; - isLoading: boolean; - error: string | null; - run: (options?: RunOptions) => Promise; -} - -export function useSpendAnalysis(): UseSpendAnalysisReturn { - const client = useOptionalAuthenticatedClient(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const run = useCallback( - async (options: RunOptions = {}) => { - setIsLoading(true); - setError(null); - try { - if (!client) { - throw new Error("Not authenticated"); - } - const result = await client.getPersonalSpendAnalysis(options); - setData(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - log.warn("Failed to fetch spend analysis", { error: message }); - setData(null); - setError(message); - } finally { - setIsLoading(false); - } - }, - [client], - ); - - return { data, isLoading, error, run }; -} diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 9f20e8141a..ae09e510b6 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -4,20 +4,18 @@ import { Info, WarningCircle, } from "@phosphor-icons/react"; -import { - formatResetTime, - PRO_USAGE_MULTIPLIER, -} from "@posthog/core/billing/usageDisplay"; -import type { UsageBucket } from "@posthog/core/usage/schemas"; +import { PRO_USAGE_MULTIPLIER } from "@posthog/core/billing/usageDisplay"; import { PLAN_PRO_ALPHA } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useSwitchOrgMutation } from "@posthog/ui/features/auth/useAuthMutations"; import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; -import { TokenSpendAnalysisBanner } from "@posthog/ui/features/billing/TokenSpendAnalysisBanner"; +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; import { useSeat } from "@posthog/ui/features/billing/useSeat"; import { useUsage } from "@posthog/ui/features/billing/useUsage"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSpendAnalysisEnabled } from "@posthog/ui/features/usage/useSpendAnalysisEnabled"; +import { navigateToUsage } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; import { getBillingUrl, getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -27,7 +25,6 @@ import { Callout, Dialog, Flex, - Progress, Spinner, Text, } from "@radix-ui/themes"; @@ -35,8 +32,6 @@ import { useEffect, useState } from "react"; const log = logger.scope("plan-usage"); -const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; - export function PlanUsageSettings() { const { seat, @@ -85,8 +80,7 @@ export function PlanUsageSettings() { ? (getPostHogUrl(redirectUrl, cloudRegion) ?? billingUrl) : null; const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - const spendAnalysisEnabled = - useFeatureFlag(SPEND_ANALYSIS_FLAG) || import.meta.env.DEV; + const spendAnalysisEnabled = useSpendAnalysisEnabled(); const isAlpha = orgSeat?.plan_key === PLAN_PRO_ALPHA; const { @@ -185,8 +179,6 @@ export function PlanUsageSettings() { )} - {spendAnalysisEnabled && } - {hasBetterPlanElsewhere && seat?.organization_name && ( @@ -330,7 +322,22 @@ export function PlanUsageSettings() { )} - Usage + + Usage + {spendAnalysisEnabled && ( + + )} + {usageLoading ? ( - - {label} - {percentage.toFixed(2)}% - - - - {bucket.exceeded ? "Limit exceeded" : formatResetTime(bucket.reset_at)} - - - ); -} - interface PlanCardProps { name: string; price: string; diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 211d722d96..1e72292d02 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -4,6 +4,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useSpendAnalysisEnabled } from "@posthog/ui/features/usage/useSpendAnalysisEnabled"; import { navigateToAgents, navigateToCommandCenter, @@ -11,6 +12,7 @@ import { navigateToInbox, navigateToMcpServers, navigateToSkills, + navigateToUsage, navigateToWebsiteCommandCenter, navigateToWebsiteHome, navigateToWebsiteMcpServers, @@ -29,6 +31,7 @@ import { McpServersItem } from "./items/McpServersItem"; import { NewTaskItem } from "./items/NewTaskItem"; import { SearchItem } from "./items/SearchItem"; import { SkillsItem } from "./items/SkillsItem"; +import { UsageItem } from "./items/UsageItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -46,18 +49,19 @@ interface SidebarNavSectionProps { // state, badge count, and click handler is wired here — so it can be dropped // into either layout. In the Channels space, destinations with a /website // mirror (Home, Skills, MCP servers, Command Center) stay in that space; -// Inbox, Agents and New task have no mirror yet and jump back to Code. Search -// opens the command menu in place. +// Inbox, Agents, Usage and New task have no mirror yet and jump back to Code. +// Search opens the command menu in place. export function SidebarNavSection({ commandCenterActiveCount: providedActiveCount, }: SidebarNavSectionProps = {}) { const view = useAppView(); const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); + const usageEnabled = useSpendAnalysisEnabled(); // When this section renders inside the Channels space, the destinations that // have a /website mirror stay in that space; everything else (and the whole - // section in the Code space) uses the canonical routes. Inbox, Agents and New - // task have no mirror yet, so they intentionally jump back to Code. + // section in the Code space) uses the canonical routes. Inbox, Agents, Usage + // and New task have no mirror yet, so they intentionally jump back to Code. const inChannels = useRouterState({ select: (s) => s.location.pathname.startsWith("/website"), }); @@ -82,6 +86,7 @@ export function SidebarNavSection({ const isCommandCenterActive = view.type === "command-center"; const isSkillsActive = view.type === "skills"; const isMcpServersActive = view.type === "mcp-servers"; + const isUsageActive = view.type === "usage"; // Open pull requests in the inbox — the main CTA, and the same count the inbox // Pull requests tab shows, so the badge and the tab always agree. @@ -152,6 +157,12 @@ export function SidebarNavSection({ + {usageEnabled && ( + + + + )} + void; +} + +export function UsageItem({ isActive, onClick }: UsageItemProps) { + return ( + } + label="Usage" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/usage/UsageView.tsx b/packages/ui/src/features/usage/UsageView.tsx new file mode 100644 index 0000000000..beb28afd7b --- /dev/null +++ b/packages/ui/src/features/usage/UsageView.tsx @@ -0,0 +1,159 @@ +import { ChartLine } from "@phosphor-icons/react"; +import { + type SpendAnalysisWindow, + windowToDays, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; +import { BILLING_FLAG } from "@posthog/shared"; +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; +import { useSeat } from "@posthog/ui/features/billing/useSeat"; +import { useUsage } from "@posthog/ui/features/billing/useUsage"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + Badge, + Box, + Button, + Flex, + ScrollArea, + Spinner, + Text, +} from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { SpendAnalysisSection } from "./components/SpendAnalysisSection"; +import { useSpendAnalysisEnabled } from "./useSpendAnalysisEnabled"; +import { useTrackUsageViewed } from "./useTrackUsageViewed"; + +export function UsageView() { + const headerContent = useMemo( + () => ( + + + + Usage + + + ), + [], + ); + useSetHeaderContent(headerContent); + + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const spendAnalysisEnabled = useSpendAnalysisEnabled(); + + const { seat, isPro, planLabel, isLoading: seatLoading } = useSeat(); + const { usage, isLoading: usageLoading } = useUsage({ + enabled: billingEnabled && seat !== null, + }); + + const [spendWindow, setSpendWindow] = useState("30d"); + + useTrackUsageViewed({ + isLoading: billingEnabled && (seatLoading || usageLoading), + isPro, + sustainedUsedPercent: usage?.sustained.used_percent ?? null, + burstUsedPercent: usage?.burst.used_percent ?? null, + spendAnalysisWindowDays: windowToDays(spendWindow), + }); + + if (!billingEnabled && !spendAnalysisEnabled) { + return ( + + + + + + + Usage isn't available + + Usage reporting isn't enabled for your account yet. + + + + + ); + } + + return ( + + + + {billingEnabled && ( + + + + + Plan usage + + {seat !== null && ( + + {planLabel} + + )} + + + + {seatLoading || usageLoading ? ( + + + + ) : usage ? ( + + + + + ) : ( + + + Unable to load usage data + + + )} + + )} + + {spendAnalysisEnabled && ( + + )} + + + + ); +} diff --git a/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx b/packages/ui/src/features/usage/components/SpendAnalysisSection.tsx similarity index 64% rename from packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx rename to packages/ui/src/features/usage/components/SpendAnalysisSection.tsx index 48f725b0b4..5ad5973db4 100644 --- a/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx +++ b/packages/ui/src/features/usage/components/SpendAnalysisSection.tsx @@ -1,5 +1,4 @@ import { - ArrowSquareOut, ChartLine, Lightning, Sparkle, @@ -9,6 +8,7 @@ import { formatTokens, formatUsd, formatWindow, + type SpendAnalysisWindow, windowDays, } from "@posthog/core/billing/spendAnalysisFormat"; import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; @@ -20,13 +20,14 @@ import type { } from "@posthog/core/billing/spendAnalysisTypes"; import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { useSpendAnalysis } from "@posthog/ui/features/billing/useSpendAnalysis"; -import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; +import { useSpendAnalysis } from "../useSpendAnalysis"; +import { WindowSelector } from "./WindowSelector"; const DOCS_URL = "https://posthog.com/docs/ai-observability"; +const PRODUCT_SCOPE = "posthog_code"; function SummaryRow({ data }: { data: SpendAnalysisResponse }) { const { summary } = data; @@ -186,10 +187,6 @@ function FooterLinks({ data }: { data: SpendAnalysisResponse }) { tool_row_count: Math.min(data.by_tool.items.length, 10), model_row_count: data.by_model.items.length, }); - // This banner lives inside the Settings dialog (modal). `navigateToTaskInput` - // changes the underlying view but the dialog stays mounted on top, so the user - // doesn't see the prefilled task input. Close the dialog first. - closeSettings(); openTaskInput({ initialPrompt: buildAnalysisPrompt(data), }); @@ -222,118 +219,106 @@ function FooterLinks({ data }: { data: SpendAnalysisResponse }) { ); } -export function TokenSpendAnalysisBanner() { - const { data, isLoading, error, run } = useSpendAnalysis(); - const triggerRun = (): void => { - void run({ dateFrom: "-30d", product: "posthog_code" }); - }; - - if (data) { - const suggestions = deriveSpendSuggestions(data); - return ( - - - - - Your PostHog Code token spend (last 30 days) - - - - - - - - - - - - Where to look - - {suggestions.map((s) => ( - - {s} - - ))} +function SpendAnalysisResults({ data }: { data: SpendAnalysisResponse }) { + const suggestions = deriveSpendSuggestions(data); + return ( + <> + + + + + + + + Where to look - + {suggestions.map((s) => ( + + {s} + + ))} - ); - } + + + ); +} - if (error) { - return ( - - - - - - - Couldn't load spend analysis - {error} - - - - - ); - } +interface SpendAnalysisSectionProps { + window: SpendAnalysisWindow; + onWindowChange: (window: SpendAnalysisWindow) => void; +} + +export function SpendAnalysisSection({ + window, + onWindowChange, +}: SpendAnalysisSectionProps) { + const { data, isLoading, isFetching, error, refetch } = useSpendAnalysis({ + window, + product: PRODUCT_SCOPE, + }); return ( - - - - - - - - Analyse your token usage with PostHog AI observability - - - See where your spend goes — by product, tool, and model — over the - last 30 days, and get tips on where to optimise. - - + + + + + Your PostHog Code token spend + + + + + + + {error ? ( + + + + + + + Couldn't load spend analysis + {error} + + + + + ) : isLoading ? ( + + - - + ) : data ? ( + + ) : null} + ); } diff --git a/packages/ui/src/features/usage/components/WindowSelector.tsx b/packages/ui/src/features/usage/components/WindowSelector.tsx new file mode 100644 index 0000000000..b3c1c3ab91 --- /dev/null +++ b/packages/ui/src/features/usage/components/WindowSelector.tsx @@ -0,0 +1,30 @@ +import type { SpendAnalysisWindow } from "@posthog/core/billing/spendAnalysisFormat"; +import { SegmentedControl } from "@radix-ui/themes"; + +const WINDOW_OPTIONS: { value: SpendAnalysisWindow; label: string }[] = [ + { value: "7d", label: "7 days" }, + { value: "30d", label: "30 days" }, + { value: "90d", label: "90 days" }, +]; + +interface WindowSelectorProps { + value: SpendAnalysisWindow; + onChange: (window: SpendAnalysisWindow) => void; +} + +export function WindowSelector({ value, onChange }: WindowSelectorProps) { + return ( + onChange(next as SpendAnalysisWindow)} + aria-label="Spend analysis window" + > + {WINDOW_OPTIONS.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/packages/ui/src/features/usage/useSpendAnalysis.ts b/packages/ui/src/features/usage/useSpendAnalysis.ts new file mode 100644 index 0000000000..8f025ef93d --- /dev/null +++ b/packages/ui/src/features/usage/useSpendAnalysis.ts @@ -0,0 +1,61 @@ +import type { SpendAnalysisResponse } from "@posthog/api-client/spend-analysis"; +import { + type SpendAnalysisWindow, + windowToDateFrom, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { logger } from "@posthog/ui/shell/logger"; +import { useQuery } from "@tanstack/react-query"; + +const log = logger.scope("spend-analysis"); + +const SPEND_ANALYSIS_STALE_TIME_MS = 60_000; + +interface UseSpendAnalysisOptions { + window: SpendAnalysisWindow; + product?: string; +} + +interface UseSpendAnalysisReturn { + data: SpendAnalysisResponse | null; + isLoading: boolean; + isFetching: boolean; + error: string | null; + refetch: () => void; +} + +export function useSpendAnalysis({ + window, + product, +}: UseSpendAnalysisOptions): UseSpendAnalysisReturn { + const client = useOptionalAuthenticatedClient(); + const query = useQuery({ + queryKey: ["billing", "spend-analysis", window, product ?? "all"], + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + try { + return await client.getPersonalSpendAnalysis({ + dateFrom: windowToDateFrom(window), + product, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + log.warn("Failed to fetch spend analysis", { error: message }); + throw err; + } + }, + enabled: client !== null, + staleTime: SPEND_ANALYSIS_STALE_TIME_MS, + }); + + return { + data: query.data ?? null, + // Not isPending: it stays true forever while the query is disabled pre-auth. + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error instanceof Error ? query.error.message : null, + refetch: () => { + void query.refetch(); + }, + }; +} diff --git a/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts b/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts new file mode 100644 index 0000000000..b60abfa567 --- /dev/null +++ b/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts @@ -0,0 +1,6 @@ +import { SPEND_ANALYSIS_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +export function useSpendAnalysisEnabled(): boolean { + return useFeatureFlag(SPEND_ANALYSIS_FLAG) || import.meta.env.DEV; +} diff --git a/packages/ui/src/features/usage/useTrackUsageViewed.ts b/packages/ui/src/features/usage/useTrackUsageViewed.ts new file mode 100644 index 0000000000..42997275d9 --- /dev/null +++ b/packages/ui/src/features/usage/useTrackUsageViewed.ts @@ -0,0 +1,41 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/shell/analytics"; +import { useEffect, useRef } from "react"; + +export interface TrackUsageViewedInput { + isLoading: boolean; + isPro: boolean; + sustainedUsedPercent: number | null; + burstUsedPercent: number | null; + spendAnalysisWindowDays: number; +} + +export function useTrackUsageViewed(input: TrackUsageViewedInput): void { + const { + isLoading, + isPro, + sustainedUsedPercent, + burstUsedPercent, + spendAnalysisWindowDays, + } = input; + + const firedRef = useRef(false); + useEffect(() => { + if (firedRef.current) return; + // Wait for data to settle so the once-only event doesn't lock in defaults. + if (isLoading) return; + firedRef.current = true; + track(ANALYTICS_EVENTS.USAGE_VIEWED, { + is_pro: isPro, + sustained_used_percent: sustainedUsedPercent, + burst_used_percent: burstUsedPercent, + spend_analysis_window_days: spendAnalysisWindowDays, + }); + }, [ + isLoading, + isPro, + sustainedUsedPercent, + burstUsedPercent, + spendAnalysisWindowDays, + ]); +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index f5859e384e..a882a8cd89 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -174,6 +174,10 @@ export function navigateToMcpServers(): void { void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); } +export function navigateToUsage(): void { + void getRouterOrNull()?.navigate({ to: "/usage" }); +} + // Channels-space mirrors. These render the same shared views as their /code (or // top-level) counterparts but under /website, so navigating from the channels // sidebar keeps the channels chrome instead of switching back to Code. The diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index c378c10fa9..ca412482a9 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebsiteRouteImport } from './routes/website' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SkillsRouteImport } from './routes/skills' import { Route as McpServersRouteImport } from './routes/mcp-servers' import { Route as CommandCenterRouteImport } from './routes/command-center' @@ -79,6 +80,11 @@ const WebsiteRoute = WebsiteRouteImport.update({ path: '/website', getParentRoute: () => rootRouteImport, } as any) +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SkillsRoute = SkillsRouteImport.update({ id: '/skills', path: '/skills', @@ -422,6 +428,7 @@ export interface FileRoutesByFullPath { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute @@ -488,6 +495,7 @@ export interface FileRoutesByTo { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/code/archived': typeof CodeArchivedRoute '/code/home': typeof CodeHomeRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -544,6 +552,7 @@ export interface FileRoutesById { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute @@ -612,6 +621,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/website' | '/code/agents' | '/code/archived' @@ -678,6 +688,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/code/archived' | '/code/home' | '/folders/$folderId' @@ -733,6 +744,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/website' | '/code/agents' | '/code/archived' @@ -800,6 +812,7 @@ export interface RootRouteChildren { CommandCenterRoute: typeof CommandCenterRoute McpServersRoute: typeof McpServersRoute SkillsRoute: typeof SkillsRoute + UsageRoute: typeof UsageRoute WebsiteRoute: typeof WebsiteRouteWithChildren CodeAgentsRoute: typeof CodeAgentsRouteWithChildren CodeArchivedRoute: typeof CodeArchivedRoute @@ -822,6 +835,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteRouteImport parentRoute: typeof rootRouteImport } + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/skills': { id: '/skills' path: '/skills' @@ -1492,6 +1512,7 @@ const rootRouteChildren: RootRouteChildren = { CommandCenterRoute: CommandCenterRoute, McpServersRoute: McpServersRoute, SkillsRoute: SkillsRoute, + UsageRoute: UsageRoute, WebsiteRoute: WebsiteRouteWithChildren, CodeAgentsRoute: CodeAgentsRouteWithChildren, CodeArchivedRoute: CodeArchivedRoute, diff --git a/packages/ui/src/router/routes/usage.tsx b/packages/ui/src/router/routes/usage.tsx new file mode 100644 index 0000000000..b85bc95cce --- /dev/null +++ b/packages/ui/src/router/routes/usage.tsx @@ -0,0 +1,6 @@ +import { UsageView } from "@posthog/ui/features/usage/UsageView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/usage")({ + component: UsageView, +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index ec2fad08d3..a3eed99447 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -18,6 +18,7 @@ export type AppViewType = | "command-center" | "skills" | "mcp-servers" + | "usage" | "settings"; export interface AppView { @@ -80,6 +81,8 @@ function deriveFromMatches(matches: Match[]): AppView { case "/mcp-servers": case "/website/mcp-servers": return { type: "mcp-servers" }; + case "/usage": + return { type: "usage" }; case "/settings/$category": case "/settings/": return { type: "settings" }; From 98600de81d605212431b4550efa22b4c32c00d3c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 20:38:00 -0700 Subject: [PATCH 2/7] restyle usage page and add daily spend chart --- packages/api-client/src/spend-analysis.ts | 8 + .../src/billing/spendAnalysisFormat.test.ts | 53 ++- .../core/src/billing/spendAnalysisFormat.ts | 48 ++- .../core/src/billing/spendAnalysisTypes.ts | 8 + packages/ui/src/features/usage/UsageView.tsx | 169 ++++++--- .../usage/components/ModelBreakdownCards.tsx | 91 +++++ .../usage/components/SpendAnalysisSection.tsx | 324 ------------------ .../usage/components/SpendBreakdownTables.tsx | 96 ++++++ .../usage/components/SpendInsights.tsx | 69 ++++ .../usage/components/SpendKpiStrip.tsx | 104 ++++++ .../usage/components/SpendOverTimeCard.tsx | 54 +++ .../features/usage/components/UsageCard.tsx | 28 ++ .../usage/components/spendDayLabel.ts | 7 + 13 files changed, 689 insertions(+), 370 deletions(-) create mode 100644 packages/ui/src/features/usage/components/ModelBreakdownCards.tsx delete mode 100644 packages/ui/src/features/usage/components/SpendAnalysisSection.tsx create mode 100644 packages/ui/src/features/usage/components/SpendBreakdownTables.tsx create mode 100644 packages/ui/src/features/usage/components/SpendInsights.tsx create mode 100644 packages/ui/src/features/usage/components/SpendKpiStrip.tsx create mode 100644 packages/ui/src/features/usage/components/SpendOverTimeCard.tsx create mode 100644 packages/ui/src/features/usage/components/UsageCard.tsx create mode 100644 packages/ui/src/features/usage/components/spendDayLabel.ts diff --git a/packages/api-client/src/spend-analysis.ts b/packages/api-client/src/spend-analysis.ts index b45e0dab83..a289ca389d 100644 --- a/packages/api-client/src/spend-analysis.ts +++ b/packages/api-client/src/spend-analysis.ts @@ -30,6 +30,12 @@ export interface SpendAnalysisModelRow { output_tokens: number; } +export interface SpendAnalysisDayRow { + day: string; + event_count: number; + cost_usd: number; +} + export interface SpendAnalysisBreakdown { items: TRow[]; truncated: boolean; @@ -40,6 +46,8 @@ export interface SpendAnalysisResponse { by_product: SpendAnalysisBreakdown; by_tool: SpendAnalysisBreakdown; by_model: SpendAnalysisBreakdown; + // Optional until the backend by_day rollout reaches every deployment. + by_day?: SpendAnalysisBreakdown; // `top_traces` is still in the backend response shape (always empty) per // posthog/posthog#59796. Renderer code does not consume it; left out of the // TS type so future readers see only what we actually use. diff --git a/packages/core/src/billing/spendAnalysisFormat.test.ts b/packages/core/src/billing/spendAnalysisFormat.test.ts index ea531eb921..c4891949ce 100644 --- a/packages/core/src/billing/spendAnalysisFormat.test.ts +++ b/packages/core/src/billing/spendAnalysisFormat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + fillSpendDays, formatTokens, type SpendAnalysisWindow, windowToDateFrom, @@ -23,10 +24,10 @@ describe("formatTokens", () => { describe("windowToDateFrom", () => { it.each<[SpendAnalysisWindow, string]>([ - ["7d", "-7d"], - ["30d", "-30d"], - ["90d", "-90d"], - ])("maps %s to %s", (window, expected) => { + ["7d", "-6dStart"], + ["30d", "-29dStart"], + ["90d", "-89dStart"], + ])("maps %s to the day-aligned %s", (window, expected) => { expect(windowToDateFrom(window)).toBe(expected); }); }); @@ -40,3 +41,47 @@ describe("windowToDays", () => { expect(windowToDays(window)).toBe(expected); }); }); + +describe("fillSpendDays", () => { + it("zero-fills days without rows across the window", () => { + const filled = fillSpendDays( + [ + { day: "2026-07-01", event_count: 3, cost_usd: 1.5 }, + { day: "2026-07-03", event_count: 1, cost_usd: 0.25 }, + ], + "2026-07-01T00:00:00Z", + "2026-07-04T12:00:00Z", + ); + expect(filled).toEqual([ + { day: "2026-07-01", event_count: 3, cost_usd: 1.5 }, + { day: "2026-07-02", event_count: 0, cost_usd: 0 }, + { day: "2026-07-03", event_count: 1, cost_usd: 0.25 }, + { day: "2026-07-04", event_count: 0, cost_usd: 0 }, + ]); + }); + + it("returns one zeroed row per day when there are no rows", () => { + const filled = fillSpendDays( + [], + "2026-07-01T06:00:00Z", + "2026-07-03T00:00:00Z", + ); + expect(filled.map((d) => d.day)).toEqual([ + "2026-07-01", + "2026-07-02", + "2026-07-03", + ]); + expect(filled.every((d) => d.cost_usd === 0 && d.event_count === 0)).toBe( + true, + ); + }); + + it("caps runaway windows instead of looping unbounded", () => { + const filled = fillSpendDays( + [], + "2020-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + ); + expect(filled.length).toBe(100); + }); +}); diff --git a/packages/core/src/billing/spendAnalysisFormat.ts b/packages/core/src/billing/spendAnalysisFormat.ts index fcbb2c964f..14dff94430 100644 --- a/packages/core/src/billing/spendAnalysisFormat.ts +++ b/packages/core/src/billing/spendAnalysisFormat.ts @@ -1,3 +1,5 @@ +import type { SpendAnalysisDayRow } from "./spendAnalysisTypes"; + export function formatUsd(amount: number): string { if (amount === 0) return "$0"; if (amount < 0.01) return "<$0.01"; @@ -14,8 +16,10 @@ export function formatTokens(n: number): string { export type SpendAnalysisWindow = "7d" | "30d" | "90d"; +// Day-aligned (`dStart`) so an N-day window resolves to exactly N UTC calendar +// days including today, giving the daily chart exactly N bars. export function windowToDateFrom(window: SpendAnalysisWindow): string { - return `-${window}`; + return `-${windowToDays(window) - 1}dStart`; } export function windowToDays(window: SpendAnalysisWindow): number { @@ -25,9 +29,49 @@ export function windowToDays(window: SpendAnalysisWindow): number { export function windowDays(fromIso: string, toIso: string): number { const fromMs = new Date(fromIso).getTime(); const toMs = new Date(toIso).getTime(); - return Math.max(1, Math.round((toMs - fromMs) / (1000 * 60 * 60 * 24))); + // Ceil: a day-aligned window ending mid-day still covers N calendar days. + return Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)); } export function formatWindow(fromIso: string, toIso: string): string { return `${windowDays(fromIso, toIso)} days`; } + +const DAY_MS = 86_400_000; +const MAX_FILLED_DAYS = 100; + +export interface SpendAnalysisFilledDay { + day: string; + event_count: number; + cost_usd: number; +} + +export function fillSpendDays( + items: SpendAnalysisDayRow[], + fromIso: string, + toIso: string, +): SpendAnalysisFilledDay[] { + const byDay = new Map(items.map((row) => [row.day, row])); + const from = new Date(fromIso); + const start = Date.UTC( + from.getUTCFullYear(), + from.getUTCMonth(), + from.getUTCDate(), + ); + const end = new Date(toIso).getTime(); + const filled: SpendAnalysisFilledDay[] = []; + for ( + let t = start; + t <= end && filled.length < MAX_FILLED_DAYS; + t += DAY_MS + ) { + const day = new Date(t).toISOString().slice(0, 10); + const row = byDay.get(day); + filled.push({ + day, + event_count: row?.event_count ?? 0, + cost_usd: row?.cost_usd ?? 0, + }); + } + return filled; +} diff --git a/packages/core/src/billing/spendAnalysisTypes.ts b/packages/core/src/billing/spendAnalysisTypes.ts index e8220111bf..36ae669812 100644 --- a/packages/core/src/billing/spendAnalysisTypes.ts +++ b/packages/core/src/billing/spendAnalysisTypes.ts @@ -30,6 +30,12 @@ export interface SpendAnalysisModelRow { output_tokens: number; } +export interface SpendAnalysisDayRow { + day: string; + event_count: number; + cost_usd: number; +} + export interface SpendAnalysisBreakdown { items: TRow[]; truncated: boolean; @@ -40,4 +46,6 @@ export interface SpendAnalysisResponse { by_product: SpendAnalysisBreakdown; by_tool: SpendAnalysisBreakdown; by_model: SpendAnalysisBreakdown; + // Optional until the backend by_day rollout reaches every deployment. + by_day?: SpendAnalysisBreakdown; } diff --git a/packages/ui/src/features/usage/UsageView.tsx b/packages/ui/src/features/usage/UsageView.tsx index beb28afd7b..9be65436da 100644 --- a/packages/ui/src/features/usage/UsageView.tsx +++ b/packages/ui/src/features/usage/UsageView.tsx @@ -1,5 +1,6 @@ -import { ChartLine } from "@phosphor-icons/react"; +import { ChartLine, CreditCard, WarningCircle } from "@phosphor-icons/react"; import { + fillSpendDays, type SpendAnalysisWindow, windowToDays, } from "@posthog/core/billing/spendAnalysisFormat"; @@ -21,16 +22,29 @@ import { Badge, Box, Button, + Callout, Flex, ScrollArea, Spinner, Text, } from "@radix-ui/themes"; import { useMemo, useState } from "react"; -import { SpendAnalysisSection } from "./components/SpendAnalysisSection"; +import { ModelBreakdownCards } from "./components/ModelBreakdownCards"; +import { + ProductBreakdownCard, + ToolBreakdownCard, +} from "./components/SpendBreakdownTables"; +import { SpendInsights } from "./components/SpendInsights"; +import { SpendKpiStrip } from "./components/SpendKpiStrip"; +import { SpendOverTimeCard } from "./components/SpendOverTimeCard"; +import { UsageCard } from "./components/UsageCard"; +import { WindowSelector } from "./components/WindowSelector"; +import { useSpendAnalysis } from "./useSpendAnalysis"; import { useSpendAnalysisEnabled } from "./useSpendAnalysisEnabled"; import { useTrackUsageViewed } from "./useTrackUsageViewed"; +const PRODUCT_SCOPE = "posthog_code"; + export function UsageView() { const headerContent = useMemo( () => ( @@ -57,6 +71,19 @@ export function UsageView() { }); const [spendWindow, setSpendWindow] = useState("30d"); + const { data, isLoading, isFetching, error, refetch } = useSpendAnalysis({ + window: spendWindow, + product: PRODUCT_SCOPE, + }); + + const filledDays = useMemo(() => { + if (!data?.by_day) return null; + return fillSpendDays( + data.by_day.items, + data.summary.date_from, + data.summary.date_to, + ); + }, [data]); useTrackUsageViewed({ isLoading: billingEnabled && (seatLoading || usageLoading), @@ -87,39 +114,105 @@ export function UsageView() { return ( - + + + + + Usage + + + Token spend and plan limits for PostHog Code + + + + {spendAnalysisEnabled && ( + + + + + )} + + + {spendAnalysisEnabled && + (error ? ( + + + + + + + + Couldn't load spend analysis + + + {error} + + + + + + ) : isLoading ? ( + + + + ) : data ? ( + <> + + {filledDays && } + + + ) : null)} + {billingEnabled && ( - - + } + title="Plan limits" + actions={ - - Plan usage - {seat !== null && ( {planLabel} )} + - - + } + > {seatLoading || usageLoading ? ( - + ) : usage ? ( - +
- +
) : ( - - - Unable to load usage data - - + + Unable to load usage data + )} -
+ )} - {spendAnalysisEnabled && ( - + {spendAnalysisEnabled && data && ( + <> +
+ + +
+ + )}
diff --git a/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx new file mode 100644 index 0000000000..cec1483abe --- /dev/null +++ b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx @@ -0,0 +1,91 @@ +import { Robot } from "@phosphor-icons/react"; +import { + formatTokens, + formatUsd, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { SpendAnalysisModelRow } from "@posthog/core/billing/spendAnalysisTypes"; +import { Flex, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +const DOT_COLORS = [ + "var(--accent-9)", + "var(--blue-9)", + "var(--purple-9)", + "var(--amber-9)", +]; + +function ModelStat({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +interface ModelBreakdownCardsProps { + rows: SpendAnalysisModelRow[]; + scopedCostUsd: number; +} + +export function ModelBreakdownCards({ + rows, + scopedCostUsd, +}: ModelBreakdownCardsProps) { + if (rows.length === 0) return null; + return ( + } + title="Cost by model" + > +
+ {rows.map((row, index) => { + const share = + scopedCostUsd > 0 + ? Math.round((row.cost_usd / scopedCostUsd) * 100) + : 0; + return ( + + + + + {row.model ?? "(unknown)"} + + + + {formatUsd(row.cost_usd)} + + +
+ + + + +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/ui/src/features/usage/components/SpendAnalysisSection.tsx b/packages/ui/src/features/usage/components/SpendAnalysisSection.tsx deleted file mode 100644 index 5ad5973db4..0000000000 --- a/packages/ui/src/features/usage/components/SpendAnalysisSection.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import { - ChartLine, - Lightning, - Sparkle, - WarningCircle, -} from "@phosphor-icons/react"; -import { - formatTokens, - formatUsd, - formatWindow, - type SpendAnalysisWindow, - windowDays, -} from "@posthog/core/billing/spendAnalysisFormat"; -import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; -import type { - SpendAnalysisModelRow, - SpendAnalysisProductRow, - SpendAnalysisResponse, - SpendAnalysisToolRow, -} from "@posthog/core/billing/spendAnalysisTypes"; -import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { track } from "@posthog/ui/shell/analytics"; -import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; -import { useSpendAnalysis } from "../useSpendAnalysis"; -import { WindowSelector } from "./WindowSelector"; - -const DOCS_URL = "https://posthog.com/docs/ai-observability"; -const PRODUCT_SCOPE = "posthog_code"; - -function SummaryRow({ data }: { data: SpendAnalysisResponse }) { - const { summary } = data; - const codeShare = - summary.total_cost_usd > 0 - ? Math.round((summary.scoped_cost_usd / summary.total_cost_usd) * 100) - : 0; - return ( - - - - - - - ); -} - -function StatCard({ - label, - value, - sub, -}: { - label: string; - value: string; - sub?: string; -}) { - return ( - - - {label} - - {value} - {sub && {sub}} - - ); -} - -function ProductTable({ rows }: { rows: SpendAnalysisProductRow[] }) { - if (rows.length === 0) return null; - return ( - - {rows.map((r) => ( - - {r.product ?? "(none)"} - {r.event_count.toLocaleString()} - {formatUsd(r.cost_usd)} - - ))} - - ); -} - -function ToolTable({ rows }: { rows: SpendAnalysisToolRow[] }) { - if (rows.length === 0) return null; - return ( - - {rows.slice(0, 10).map((r) => ( - - {r.tool ?? "(no tool)"} - {r.generation_count.toLocaleString()} - {formatTokens(r.avg_input_tokens)} - {formatUsd(r.cost_usd)} - - ))} - - ); -} - -function ModelTable({ rows }: { rows: SpendAnalysisModelRow[] }) { - if (rows.length === 0) return null; - return ( - - {rows.map((r) => ( - - {r.model ?? "(unknown)"} - {r.generation_count.toLocaleString()} - {formatTokens(r.input_tokens)} - {formatTokens(r.output_tokens)} - {formatUsd(r.cost_usd)} - - ))} - - ); -} - -function SectionTable({ - title, - headers, - widths, - children, -}: { - title: string; - headers: string[]; - widths: string[]; - children: React.ReactNode; -}) { - return ( - - {title} - - - - {headers.map((h, i) => ( - - {h} - - ))} - - - {children} - - - ); -} - -function FooterLinks({ data }: { data: SpendAnalysisResponse }) { - const handleAnalyseClick = (): void => { - track(ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED, { - total_cost_usd: data.summary.total_cost_usd, - scoped_cost_usd: data.summary.scoped_cost_usd, - scoped_event_count: data.summary.scoped_event_count, - window_days: windowDays(data.summary.date_from, data.summary.date_to), - tool_row_count: Math.min(data.by_tool.items.length, 10), - model_row_count: data.by_model.items.length, - }); - openTaskInput({ - initialPrompt: buildAnalysisPrompt(data), - }); - }; - - return ( - - - Use{" "} - - PostHog AI observability - {" "} - in your own project for the full slice-and-dice experience. - - - - ); -} - -function SpendAnalysisResults({ data }: { data: SpendAnalysisResponse }) { - const suggestions = deriveSpendSuggestions(data); - return ( - <> - - - - - - - - Where to look - - {suggestions.map((s) => ( - - {s} - - ))} - - - - ); -} - -interface SpendAnalysisSectionProps { - window: SpendAnalysisWindow; - onWindowChange: (window: SpendAnalysisWindow) => void; -} - -export function SpendAnalysisSection({ - window, - onWindowChange, -}: SpendAnalysisSectionProps) { - const { data, isLoading, isFetching, error, refetch } = useSpendAnalysis({ - window, - product: PRODUCT_SCOPE, - }); - - return ( - - - - - Your PostHog Code token spend - - - - - - - {error ? ( - - - - - - - Couldn't load spend analysis - {error} - - - - - ) : isLoading ? ( - - - - ) : data ? ( - - ) : null} - - ); -} diff --git a/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx b/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx new file mode 100644 index 0000000000..05eacfe81b --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx @@ -0,0 +1,96 @@ +import { Stack, Wrench } from "@phosphor-icons/react"; +import { + formatTokens, + formatUsd, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { + SpendAnalysisProductRow, + SpendAnalysisToolRow, +} from "@posthog/core/billing/spendAnalysisTypes"; +import { Table, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +function BreakdownTable({ + headers, + widths, + children, +}: { + headers: string[]; + widths: string[]; + children: React.ReactNode; +}) { + return ( + + + + {headers.map((h, i) => ( + + {h} + + ))} + + + {children} + + ); +} + +export function ToolBreakdownCard({ rows }: { rows: SpendAnalysisToolRow[] }) { + if (rows.length === 0) return null; + return ( + } + title="By tool" + > + + {rows.slice(0, 10).map((r) => ( + + {r.tool ?? "(no tool)"} + {r.generation_count.toLocaleString()} + {formatTokens(r.avg_input_tokens)} + {formatUsd(r.cost_usd)} + + ))} + + + ); +} + +export function ProductBreakdownCard({ + rows, +}: { + rows: SpendAnalysisProductRow[]; +}) { + if (rows.length === 0) return null; + return ( + } + title="By product" + > + + {rows.map((r) => ( + + + {r.product ?? "(none)"} + + {r.event_count.toLocaleString()} + {formatUsd(r.cost_usd)} + + ))} + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendInsights.tsx b/packages/ui/src/features/usage/components/SpendInsights.tsx new file mode 100644 index 0000000000..deb02cfa43 --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendInsights.tsx @@ -0,0 +1,69 @@ +import { Sparkle } from "@phosphor-icons/react"; +import { windowDays } from "@posthog/core/billing/spendAnalysisFormat"; +import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; +import type { SpendAnalysisResponse } from "@posthog/core/billing/spendAnalysisTypes"; +import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; +import { Button, Flex, Separator, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +const DOCS_URL = "https://posthog.com/docs/ai-observability"; + +export function SpendInsights({ data }: { data: SpendAnalysisResponse }) { + const suggestions = deriveSpendSuggestions(data); + + const handleAnalyseClick = (): void => { + track(ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED, { + total_cost_usd: data.summary.total_cost_usd, + scoped_cost_usd: data.summary.scoped_cost_usd, + scoped_event_count: data.summary.scoped_event_count, + window_days: windowDays(data.summary.date_from, data.summary.date_to), + tool_row_count: Math.min(data.by_tool.items.length, 10), + model_row_count: data.by_model.items.length, + }); + openTaskInput({ + initialPrompt: buildAnalysisPrompt(data), + }); + }; + + return ( + } + title="Where to look" + > + + {suggestions.map((s) => ( + + {s} + + ))} + + + + + Use{" "} + + PostHog AI observability + {" "} + in your own project for the full slice-and-dice experience. + + + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendKpiStrip.tsx b/packages/ui/src/features/usage/components/SpendKpiStrip.tsx new file mode 100644 index 0000000000..bcd83dd250 --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendKpiStrip.tsx @@ -0,0 +1,104 @@ +import { + formatUsd, + type SpendAnalysisFilledDay, + windowDays, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { SpendAnalysisResponse } from "@posthog/core/billing/spendAnalysisTypes"; +import { MetricCard, useChartTheme } from "@posthog/quill-charts"; +import { Flex } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { spendDayLabel } from "./spendDayLabel"; + +const tileTitle = (label: string): ReactNode => ( + + {label} + +); + +function KpiCell({ children, last }: { children: ReactNode; last?: boolean }) { + return ( +
+ {children} +
+ ); +} + +interface SpendKpiStripProps { + data: SpendAnalysisResponse; + filledDays: SpendAnalysisFilledDay[] | null; +} + +export function SpendKpiStrip({ data, filledDays }: SpendKpiStripProps) { + const theme = useChartTheme(); + const { summary } = data; + const codeShare = + summary.total_cost_usd > 0 + ? Math.round((summary.scoped_cost_usd / summary.total_cost_usd) * 100) + : 0; + const labels = filledDays?.map((d) => spendDayLabel(d.day)); + const costSeries = filledDays?.map((d) => Math.max(0, d.cost_usd)); + const eventSeries = filledDays?.map((d) => d.event_count); + const latestDay = filledDays?.at(-1); + + return ( + + + + + + + + + v.toLocaleString()} + change={null} + sparklineHeight={28} + /> + + + {latestDay ? ( + + ) : ( + `${v} days`} + change={null} + /> + )} + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx b/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx new file mode 100644 index 0000000000..8354c41efd --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx @@ -0,0 +1,54 @@ +import { ChartBar } from "@phosphor-icons/react"; +import { + formatUsd, + type SpendAnalysisFilledDay, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { + type Series, + TimeSeriesBarChart, + useChartTheme, +} from "@posthog/quill-charts"; +import { UsageCard } from "./UsageCard"; + +const VALUE_LABEL_MAX_BARS = 31; + +interface SpendOverTimeCardProps { + filledDays: SpendAnalysisFilledDay[]; +} + +export function SpendOverTimeCard({ filledDays }: SpendOverTimeCardProps) { + const theme = useChartTheme(); + const series: Series[] = [ + { + key: "cost", + label: "Cost (USD)", + data: filledDays.map((d) => Math.max(0, d.cost_usd)), + }, + ]; + const showValueLabels = filledDays.length <= VALUE_LABEL_MAX_BARS; + return ( + } + title="Cost over time" + > + {/* flex-col + fixed height: the quill chart sizes its canvas by filling + a flex-column parent; a plain block collapses it to 0. */} +
+ d.day)} + config={{ + xAxis: { timezone: "UTC", interval: "day" }, + yAxis: { tickFormatter: formatUsd }, + valueLabels: showValueLabels + ? { formatter: (value) => (value > 0 ? formatUsd(value) : "") } + : false, + barCornerRadius: 2, + showCrosshair: true, + }} + theme={theme} + /> +
+
+ ); +} diff --git a/packages/ui/src/features/usage/components/UsageCard.tsx b/packages/ui/src/features/usage/components/UsageCard.tsx new file mode 100644 index 0000000000..eba55c7776 --- /dev/null +++ b/packages/ui/src/features/usage/components/UsageCard.tsx @@ -0,0 +1,28 @@ +import { Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; + +interface UsageCardProps { + icon?: ReactNode; + title: string; + actions?: ReactNode; + children: ReactNode; +} + +export function UsageCard({ icon, title, actions, children }: UsageCardProps) { + return ( + + + {icon} + {title} + + {actions} + + {children} + + ); +} diff --git a/packages/ui/src/features/usage/components/spendDayLabel.ts b/packages/ui/src/features/usage/components/spendDayLabel.ts new file mode 100644 index 0000000000..172fad959d --- /dev/null +++ b/packages/ui/src/features/usage/components/spendDayLabel.ts @@ -0,0 +1,7 @@ +export function spendDayLabel(day: string): string { + return new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + timeZone: "UTC", + }); +} From 89f832be79d32c5790b11a7af94b68b246381c87 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 20:38:03 -0700 Subject: [PATCH 3/7] remove em dashes from spend analysis copy --- packages/core/src/billing/spendAnalysisPrompt.test.ts | 2 +- packages/core/src/billing/spendAnalysisPrompt.ts | 8 ++++---- packages/core/src/billing/spendSuggestions.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/billing/spendAnalysisPrompt.test.ts b/packages/core/src/billing/spendAnalysisPrompt.test.ts index ffa1ef592c..97cbe7c323 100644 --- a/packages/core/src/billing/spendAnalysisPrompt.test.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.test.ts @@ -188,7 +188,7 @@ describe("buildAnalysisPrompt", () => { it("instructs the agent not to query external data", () => { const prompt = buildAnalysisPrompt(makeResponse()); expect(prompt).toContain( - "do **not** try to query PostHog AI observability or any external data source", + "Do **not** try to query PostHog AI observability or any external data source", ); }); }); diff --git a/packages/core/src/billing/spendAnalysisPrompt.ts b/packages/core/src/billing/spendAnalysisPrompt.ts index 8ada318cd4..c81dc12537 100644 --- a/packages/core/src/billing/spendAnalysisPrompt.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.ts @@ -41,13 +41,13 @@ const PLAYBOOK = `## What to look at Use this playbook to interpret the numbers above. Apply the levers in order of impact; not every lever applies to every user. -1. **Input tokens are the bill, not the tool calls themselves.** "Avg input" per tool is the context size dragged along on every call. A tool being expensive almost never means the tool itself is expensive — it means there were many calls each carrying a fat context. The biggest lever is conversation length, not which tool gets called: compact aggressively at logical checkpoints, start fresh sessions for unrelated tasks, avoid backtracking ("actually try X instead") because that re-runs all the prior context plus the alternative. +1. **Input tokens are the bill, not the tool calls themselves.** "Avg input" per tool is the context size dragged along on every call. A tool being expensive almost never means the tool itself is expensive. It means there were many calls each carrying a fat context. The biggest lever is conversation length, not which tool gets called: compact aggressively at logical checkpoints, start fresh sessions for unrelated tasks, avoid backtracking ("actually try X instead") because that re-runs all the prior context plus the alternative. 2. **Model choice.** Look at the "By model" table. If most generations are on the most expensive available model, switching the default to a mid-tier model and only escalating for genuinely hard reasoning is often the single biggest dollar saver. The cheapest tier is essentially free per call for routine work (run a test, check git status, grep for a string). -3. **Subagent hygiene.** The Agent / subagent tool typically has a high avg input because subagents inherit a brief plus the tool registry. They're worth their cost when they protect the main conversation from a long exploration; they're not worth it for "read one file" or "grep one pattern" — use the direct tool. +3. **Subagent hygiene.** The Agent / subagent tool typically has a high avg input because subagents inherit a brief plus the tool registry. They're worth their cost when they protect the main conversation from a long exploration; they're not worth it for "read one file" or "grep one pattern". Use the direct tool for those. -4. **No-tool replies.** If the "By tool" table has a "(no tool)" row, that's the model replying with pure text — no action. Some of that is unavoidable (answering a question), some is the model thinking out loud or asking clarifying questions when it could just act. If this share is greater than ~10% of spend, more directive prompts ("Just do X" instead of "What do you think about X?") cut a round-trip per task. +4. **No-tool replies.** If the "By tool" table has a "(no tool)" row, that's the model replying with pure text, no action. Some of that is unavoidable (answering a question), some is the model thinking out loud or asking clarifying questions when it could just act. If this share is greater than ~10% of spend, more directive prompts ("Just do X" instead of "What do you think about X?") cut a round-trip per task. 5. **MCP / tool-registry overhead.** Tool calls that route through MCP (or any plugin layer that ships a tool registry on every turn) often show inflated avg input. If the user has many MCP servers enabled, pruning the ones they don't use shrinks the per-call overhead. @@ -93,7 +93,7 @@ export function buildAnalysisPrompt(data: SpendAnalysisResponse): string { return `Here is my PostHog Code LLM spend for the last ${windowLabel}. Help me understand what's driving the cost and what concrete changes I should make to reduce it. -Work only from the tables below — do **not** try to query PostHog AI observability or any external data source. The numbers here are everything you have. Rank advice by impact, lead with the biggest lever, and keep each suggestion concrete and actionable. +Work only from the tables below. Do **not** try to query PostHog AI observability or any external data source. The numbers here are everything you have. Rank advice by impact, lead with the biggest lever, and keep each suggestion concrete and actionable. ## My spend diff --git a/packages/core/src/billing/spendSuggestions.ts b/packages/core/src/billing/spendSuggestions.ts index 4857855450..9b0a818672 100644 --- a/packages/core/src/billing/spendSuggestions.ts +++ b/packages/core/src/billing/spendSuggestions.ts @@ -23,20 +23,20 @@ export function deriveSpendSuggestions(data: SpendAnalysisResponse): string[] { const top = toolItems[0]; if (top.share_of_scoped > 0.35 && top.tool) { suggestions.push( - `${top.tool} drives ${Math.round(top.share_of_scoped * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, + `${top.tool} drives ${Math.round(top.share_of_scoped * 100)}% of your PostHog Code spend, averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, ); } const noToolRow = toolItems.find((r) => r.tool === null); if (noToolRow && noToolRow.share_of_scoped > 0.1) { suggestions.push( - `${Math.round(noToolRow.share_of_scoped * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, + `${Math.round(noToolRow.share_of_scoped * 100)}% is spent on generations that take no tool action: pure text replies. Consider tighter prompts or stopping the agent earlier.`, ); } } if (suggestions.length === 0) { suggestions.push( - "Your spend is fairly evenly distributed across tools — no single hotspot stands out.", + "Your spend is fairly evenly distributed across tools. No single hotspot stands out.", ); } From a5f49d31aadcb159cbbf039be0b3f363ecddd97b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 21:01:55 -0700 Subject: [PATCH 4/7] remove constant window property and unsafe cast --- packages/shared/src/analytics-events.ts | 1 - packages/ui/src/features/usage/UsageView.tsx | 2 -- .../usage/components/WindowSelector.tsx | 5 ++++- .../src/features/usage/useTrackUsageViewed.ts | 18 ++---------------- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index e7d6173f69..d0367d357b 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -632,7 +632,6 @@ export interface UsageViewedProperties { sustained_used_percent: number | null; /** Daily bucket percent (0-100), null when usage is unavailable. */ burst_used_percent: number | null; - spend_analysis_window_days: number; } export interface SpendAnalysisTaskOpenedProperties { diff --git a/packages/ui/src/features/usage/UsageView.tsx b/packages/ui/src/features/usage/UsageView.tsx index 9be65436da..396718ccd3 100644 --- a/packages/ui/src/features/usage/UsageView.tsx +++ b/packages/ui/src/features/usage/UsageView.tsx @@ -2,7 +2,6 @@ import { ChartLine, CreditCard, WarningCircle } from "@phosphor-icons/react"; import { fillSpendDays, type SpendAnalysisWindow, - windowToDays, } from "@posthog/core/billing/spendAnalysisFormat"; import { Empty, @@ -90,7 +89,6 @@ export function UsageView() { isPro, sustainedUsedPercent: usage?.sustained.used_percent ?? null, burstUsedPercent: usage?.burst.used_percent ?? null, - spendAnalysisWindowDays: windowToDays(spendWindow), }); if (!billingEnabled && !spendAnalysisEnabled) { diff --git a/packages/ui/src/features/usage/components/WindowSelector.tsx b/packages/ui/src/features/usage/components/WindowSelector.tsx index b3c1c3ab91..208ef94c5e 100644 --- a/packages/ui/src/features/usage/components/WindowSelector.tsx +++ b/packages/ui/src/features/usage/components/WindowSelector.tsx @@ -17,7 +17,10 @@ export function WindowSelector({ value, onChange }: WindowSelectorProps) { onChange(next as SpendAnalysisWindow)} + onValueChange={(next) => { + const found = WINDOW_OPTIONS.find((option) => option.value === next); + if (found) onChange(found.value); + }} aria-label="Spend analysis window" > {WINDOW_OPTIONS.map((option) => ( diff --git a/packages/ui/src/features/usage/useTrackUsageViewed.ts b/packages/ui/src/features/usage/useTrackUsageViewed.ts index 42997275d9..32775c6ceb 100644 --- a/packages/ui/src/features/usage/useTrackUsageViewed.ts +++ b/packages/ui/src/features/usage/useTrackUsageViewed.ts @@ -7,17 +7,10 @@ export interface TrackUsageViewedInput { isPro: boolean; sustainedUsedPercent: number | null; burstUsedPercent: number | null; - spendAnalysisWindowDays: number; } export function useTrackUsageViewed(input: TrackUsageViewedInput): void { - const { - isLoading, - isPro, - sustainedUsedPercent, - burstUsedPercent, - spendAnalysisWindowDays, - } = input; + const { isLoading, isPro, sustainedUsedPercent, burstUsedPercent } = input; const firedRef = useRef(false); useEffect(() => { @@ -29,13 +22,6 @@ export function useTrackUsageViewed(input: TrackUsageViewedInput): void { is_pro: isPro, sustained_used_percent: sustainedUsedPercent, burst_used_percent: burstUsedPercent, - spend_analysis_window_days: spendAnalysisWindowDays, }); - }, [ - isLoading, - isPro, - sustainedUsedPercent, - burstUsedPercent, - spendAnalysisWindowDays, - ]); + }, [isLoading, isPro, sustainedUsedPercent, burstUsedPercent]); } From b47a4b0035074e15cfcc810e6aa96e549db82d05 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 21:58:41 -0700 Subject: [PATCH 5/7] Update UsageView.tsx --- packages/ui/src/features/usage/UsageView.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/features/usage/UsageView.tsx b/packages/ui/src/features/usage/UsageView.tsx index 396718ccd3..8fbe216c8d 100644 --- a/packages/ui/src/features/usage/UsageView.tsx +++ b/packages/ui/src/features/usage/UsageView.tsx @@ -184,6 +184,16 @@ export function UsageView() { ) : null)} + {spendAnalysisEnabled && data && ( + <> +
+ + +
+ + + )} + {billingEnabled && ( } @@ -229,16 +239,6 @@ export function UsageView() { )} )} - - {spendAnalysisEnabled && data && ( - <> -
- - -
- - - )}
From 4a7bcba92f8570c1b4a5f9846bd2d903c9557e30 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 22:08:03 -0700 Subject: [PATCH 6/7] remove decorative dots from model cards --- .../usage/components/ModelBreakdownCards.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx index cec1483abe..9b03a9906f 100644 --- a/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx +++ b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx @@ -7,13 +7,6 @@ import type { SpendAnalysisModelRow } from "@posthog/core/billing/spendAnalysisT import { Flex, Text } from "@radix-ui/themes"; import { UsageCard } from "./UsageCard"; -const DOT_COLORS = [ - "var(--accent-9)", - "var(--blue-9)", - "var(--purple-9)", - "var(--amber-9)", -]; - function ModelStat({ label, value }: { label: string; value: string }) { return ( @@ -39,7 +32,7 @@ export function ModelBreakdownCards({ title="Cost by model" >
- {rows.map((row, index) => { + {rows.map((row) => { const share = scopedCostUsd > 0 ? Math.round((row.cost_usd / scopedCostUsd) * 100) @@ -53,12 +46,6 @@ export function ModelBreakdownCards({ className="rounded-(--radius-2) border border-(--gray-5) bg-(--gray-2)" > - {row.model ?? "(unknown)"} From 2a503cb2418be93819965758de1cf2c20a31fdc4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 5 Jul 2026 22:08:03 -0700 Subject: [PATCH 7/7] move usage item below command center --- .../sidebar/components/SidebarNavSection.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 1e72292d02..77162f0759 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -157,19 +157,19 @@ export function SidebarNavSection({ - {usageEnabled && ( - - - - )} - - + + + {usageEnabled && ( + + + + )} ); }