import { useNavigation } from "@react-navigation/native"; import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; import { enumerateDays, enumerateHourStarts, formatCount, formatDayShort, formatHourShort, formatPercent, formatTokens, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; import { useMemo, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; 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 { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; 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 { merged, environments, isPending, isPartial, refresh } = useUsage(window); const limits = useRefreshLimits(); 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], ); // The pull spinner tracks re-scans of environments that have answered // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever — neither may pin the spinner on. const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; const refreshWindow = () => { 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 ) { refresh(); } else { setWindowSelection({ days: windowDays, window: nextWindow }); } }; return ( {Platform.OS === "android" ? ( <> navigation.goBack()} /> ) : 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. */} {isPending ? ( Scanning provider transcripts… ) : environments.length === 0 ? ( Connect 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 ( {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", active && "bg-subtle-strong", )} > {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} {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens {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 UsageCoverageNotice(props: { readonly environments: readonly EnvironmentUsageStatus[]; readonly merged: MergedUsage; readonly isPartial: boolean; }) { const failed = props.environments.filter((environment) => environment.error !== null); const stale = props.environments.filter((environment) => props.merged.staleEnvironments.includes(environment.environmentId), ); const duplicateSources = props.merged.duplicateSources; if ( failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !props.isPartial ) { return null; } return ( {props.isPartial ? ( Some environments are still reporting. Totals are partial. ) : null} {failed.map((environment) => ( {environment.label} could not report usage. ))} {stale.map((environment) => ( {environment.label} runs an older server version and is excluded from totals. ))} {duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} {duplicateSources.join(", ")} ) : null} ); }