import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, type DailyTotals, type MergedUsage, } from "@t3tools/shared/usageMerge"; import { enumerateDays, enumerateHourStarts, formatCount, formatDayShort, formatHourShort, formatPercent, formatTokens, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; import { useRefreshLimits } from "./UsageLimitsSection"; import { UsageLimitsSection } from "./UsageLimitsPooled"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ { value: "usage", label: "Usage" }, { value: "limits", label: "Limits" }, ] as const satisfies readonly { value: UsageTab; label: string }[]; // Labels are abbreviated to share a row with the metric toggle; screen // readers get the full phrase. const WINDOW_OPTIONS = [ { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, ] as const; const METRIC_OPTIONS = [ { value: "cost", label: "Cost" }, { value: "tokens", label: "Tokens" }, ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; /** * Two tabs over one screen. Usage is the transcript-derived spend for a * period; Limits is the live subscription quota, which has no period. Both * pull to refresh, each refreshing its own data. */ export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [tab, setTab] = useState("usage"); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( window, selectedEnvironmentIds, ); const limits = useRefreshLimits(selectedEnvironmentIds); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], ); const chartDays = useMemo( () => isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined ? enumerateHourStarts(window.sinceTime, window.untilTime) : days, [days, isPast24Hours, window.sinceTime, window.untilTime], ); const chartTotals = useMemo( (): readonly DailyTotals[] => isPast24Hours ? merged.hourly.map((hour) => ({ day: hour.hourStart, costUsd: hour.costUsd, totalTokens: hour.totalTokens, byProvider: hour.byProvider, })) : merged.daily, [isPast24Hours, merged.daily, merged.hourly], ); const [refreshingUsage, setRefreshingUsage] = useState(false); const refreshingRef = useRef(false); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; const refreshWindow = () => { if (refreshingRef.current) return; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { setWindowSelection({ days: windowDays, window: nextWindow }); } refreshingRef.current = true; setRefreshingUsage(true); void refresh(nextWindow).finally(() => { refreshingRef.current = false; setRefreshingUsage(false); }); }; const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); const filterAccessibilityLabel = hasLoadingEnvironments ? "Filter usage environments, some environments are loading" : "Filter usage environments"; const filterIcon = selectedEnvironmentIds === null ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill"; const environmentActions = useMemo( () => [ { id: "all", title: "All environments", subtitle: undefined, state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), }, ...environments.map((environment) => ({ id: environment.environmentId, title: environment.label, subtitle: usageEnvironmentStatus(environment), state: selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) ? ("on" as const) : ("off" as const), })), ], [environments, selectedEnvironmentIds], ); const selectEnvironment = useCallback( (value: string) => { if (value === "all") { setSelectedEnvironmentIds(null); return; } const id = EnvironmentId.make(value); setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); }, [environments], ); const environmentFilter = useMemo( () => showEnvironmentFilter ? ( selectEnvironment(nativeEvent.event)} > {hasLoadingEnvironments ? ( ) : null} ) : null, [ showEnvironmentFilter, environmentActions, selectEnvironment, filterAccessibilityLabel, filterIcon, hasLoadingEnvironments, ], ); useLayoutEffect(() => { if (Platform.OS === "ios") { navigation.setOptions({ headerRight: () => environmentFilter }); } }, [navigation, environmentFilter]); return ( {Platform.OS === "android" ? ( <> navigation.goBack()} trailing={environmentFilter} /> ) : null} void limits.refresh() : refreshWindow} /> } > {showingLimits ? ( ) : ( <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} {merged.duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} {merged.duplicateSources.join(", ")} ) : null} {isPending ? ( Scanning provider transcripts… ) : selectedEnvironments.length === 0 ? ( {environments.length === 0 ? "Connect an environment to see usage." : "Select an environment to see usage."} ) : ( <> )} )} ); } function SegmentedControl(props: { readonly options: readonly { readonly value: Value; readonly label: string; readonly accessibilityLabel?: string; }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; /** The tab bar is full height; filters under it are shorter so it stays primary. */ readonly size?: "default" | "compact"; /** "tab" for the view switcher; filters stay plain buttons. */ readonly role?: "tab" | "button"; readonly className?: string; }) { const compact = props.size === "compact"; return ( option.value === props.selected), ) * 100) / props.options.length }%`, }} /> {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", )} > {option.label} ); })} ); } /** Headline figure, the animated daily chart, and its legend, in one card. */ function ChartCard(props: { readonly merged: MergedUsage; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; readonly metric: UsageChartMetric; readonly sinceDay: string; readonly untilDay: string; readonly isPast24Hours: boolean; readonly timeZone: string; }) { const { merged, metric } = props; const colors = useProviderColors(); const hasActivity = props.daily.some((period) => period.totalTokens > 0); return ( {metric === "cost" ? "Raw token cost" : "Processed tokens"} {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" ? "* if billed at full API rate" : `Across ${formatCount(merged.sessions)} sessions`} {hasActivity ? ( ) : ( No activity in this window. )} {props.isPast24Hours ? formatHourShort(props.days[0] ?? "", props.timeZone) : formatDayShort(props.sinceDay)} {merged.providers.map((provider) => ( {PROVIDER_LABEL[provider.provider]} ))} {props.isPast24Hours ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone) : formatDayShort(props.untilDay)} ); } function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; }) { const { merged, metric } = props; const colors = useProviderColors(); if (merged.providers.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); return ( {ordered.map((provider, index) => { const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( {PROVIDER_LABEL[provider.provider]} {metric === "cost" ? formatUsd(provider.costUsd) : formatTokens(provider.totalTokens)} {metric === "cost" ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} ); })} ); } function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) { const { merged } = props; const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter( (period) => period.totalTokens > 0, ).length; const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; return ( 0 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` : "vs full input rates" } /> ); } function MetricCell(props: { readonly label: string; readonly value: string; readonly detail: string; }) { return ( {props.label} {props.value} {props.detail} ); } function ModelsSection(props: { readonly merged: MergedUsage }) { const { merged } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; return ( {merged.models.map((model, index) => ( {model.model} {isModelCostUnknown(model) ? `no known rates · ${formatTokens(model.totalTokens)} tokens` : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} ))} ); } /** * Says plainly when the totals are incomplete: an environment still answering, * one that failed, or one whose transcripts another environment already * reported. */ function isUsageLoading(environment: EnvironmentUsageStatus) { return environment.isPending || (environment.summary === null && environment.error === null); } function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { if ( environment.summary && !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { return "Older server · excluded from usage totals"; } if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; if (environment.error) return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; if (isUsageLoading(environment)) return environment.summary ? "Updating usage…" : "Loading usage…"; return "Usage up to date"; }