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<UsageTab>("usage"); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), })); const [metric, setMetric] = useState<UsageChartMetric>("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<ReadonlySet<EnvironmentId> | 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 ? ( <ControlPillMenu accessible accessibilityRole="button" accessibilityLabel={filterAccessibilityLabel} title="Environments" actions={environmentActions} onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)} > <Pressable accessibilityRole="button" accessibilityLabel={filterAccessibilityLabel} className={cn( "items-center justify-center rounded-full", Platform.OS === "ios" ? "size-[28px]" : "size-[44px]", )} > <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" /> {hasLoadingEnvironments ? ( <View pointerEvents="none" className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500" /> ) : null} </Pressable> </ControlPillMenu> ) : null, [ showEnvironmentFilter, environmentActions, selectEnvironment, filterAccessibilityLabel, filterIcon, hasLoadingEnvironments, ], ); useLayoutEffect(() => { if (Platform.OS === "ios") { navigation.setOptions({ headerRight: () => environmentFilter }); } }, [navigation, environmentFilter]); return ( <View collapsable={false} className="flex-1 bg-sheet"> {Platform.OS === "android" ? ( <> <NativeStackScreenOptions options={{ headerShown: false }} /> <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} trailing={environmentFilter} /> </> ) : null} <ScrollView contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} refreshControl={ <RefreshControl refreshing={showingLimits ? limits.refreshing : refreshingUsage} onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} /> } > <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> <Animated.View key={tab} entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)} className="gap-6" > {showingLimits ? ( <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} selectedEnvironmentIds={selectedEnvironmentIds} /> ) : ( <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} <View className="flex-row items-center gap-3"> <SegmentedControl options={WINDOW_OPTIONS} selected={windowDays} onSelect={selectWindow} size="compact" className="flex-1" /> <SegmentedControl options={METRIC_OPTIONS} selected={metric} onSelect={setMetric} size="compact" className="w-36" /> </View> {merged.duplicateSources.length > 0 ? ( <Text className="text-sm text-foreground-muted"> Counted once across environments sharing a transcript directory:{" "} {merged.duplicateSources.join(", ")} </Text> ) : null} {isPending ? ( <Text className="py-16 text-center text-base text-foreground-muted"> Scanning provider transcripts… </Text> ) : selectedEnvironments.length === 0 ? ( <Text className="py-16 text-center text-base text-foreground-muted"> {environments.length === 0 ? "Connect an environment to see usage." : "Select an environment to see usage."} </Text> ) : ( <> <ChartCard merged={merged} days={chartDays} daily={chartTotals} metric={metric} sinceDay={window.sinceDay} untilDay={window.untilDay} isPast24Hours={isPast24Hours} timeZone={window.timeZone} /> <ProviderSection merged={merged} metric={metric} /> <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> <ModelsSection merged={merged} /> </> )} </> )} </Animated.View> </ScrollView> </View> );} function SegmentedControl<Value extends number | string>(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 ( <View accessible={false} className={cn( "flex-row overflow-hidden rounded-full border-continuous bg-card", props.className, )} > <Animated.View pointerEvents="none" layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" style={{ width: `${100 / props.options.length}%`, start: `${ (Math.max( 0, props.options.findIndex((option) => option.value === props.selected), ) * 100) / props.options.length }%`, }} /> {props.options.map((option) => { const active = option.value === props.selected; return ( <Pressable key={String(option.value)} accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")} accessibilityLabel={option.accessibilityLabel ?? option.label} accessibilityState={{ selected: active }} onPress={() => props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", )} > <Text className={cn( compact ? "text-xs" : "text-sm", active ? "font-t3-medium text-foreground" : "text-foreground-muted", )} > {option.label} </Text> </Pressable> ); })} </View> );} /** 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 ( <View className="gap-4 rounded-[24px] border-continuous bg-card p-4"> <View className="gap-0.5"> <Text className="text-sm text-foreground-muted"> {metric === "cost" ? "Raw token cost" : "Processed tokens"} </Text> <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} </Text> <Text className="text-sm text-foreground-muted"> {metric === "cost" ? "* if billed at full API rate" : `Across ${formatCount(merged.sessions)} sessions`} </Text> </View> {hasActivity ? ( <UsageDailyChart days={props.days} daily={props.daily} metric={metric} height={CHART_HEIGHT} /> ) : ( <View style={{ height: CHART_HEIGHT }} className="items-center justify-center"> <Text className="text-base text-foreground-muted">No activity in this window.</Text> </View> )} <View className="flex-row items-center justify-between"> <Text className="text-xs text-foreground-tertiary"> {props.isPast24Hours ? formatHourShort(props.days[0] ?? "", props.timeZone) : formatDayShort(props.sinceDay)} </Text> <View className="flex-row items-center gap-4"> {merged.providers.map((provider) => ( <View key={provider.provider} className="flex-row items-center gap-1.5"> <View className="size-2 rounded-full" style={{ backgroundColor: colors[provider.provider] }} /> <Text className="text-xs text-foreground-muted"> {PROVIDER_LABEL[provider.provider]} </Text> </View> ))} </View> <Text className="text-xs text-foreground-tertiary"> {props.isPast24Hours ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone) : formatDayShort(props.untilDay)} </Text> </View> </View> );} 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 ( <SettingsSection title="Providers" card> {ordered.map((provider, index) => { const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( <View key={provider.provider} className={index === 0 ? "gap-2 p-4" : "gap-2 border-t border-border-subtle p-4"} > <View className="flex-row items-baseline justify-between gap-3"> <View className="flex-row items-center gap-2"> <View className="size-2.5 rounded-full" style={{ backgroundColor: colors[provider.provider] }} /> <Text className="text-lg text-foreground">{PROVIDER_LABEL[provider.provider]}</Text> </View> <Text className="text-lg tabular-nums text-foreground"> {metric === "cost" ? formatUsd(provider.costUsd) : formatTokens(provider.totalTokens)} </Text>Lines 1–500 of 648. Load more
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<UsageTab>("usage"); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), })); const [metric, setMetric] = useState<UsageChartMetric>("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<ReadonlySet<EnvironmentId> | 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 ? ( <ControlPillMenu accessible accessibilityRole="button" accessibilityLabel={filterAccessibilityLabel} title="Environments" actions={environmentActions} onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)} > <Pressable accessibilityRole="button" accessibilityLabel={filterAccessibilityLabel} className={cn( "items-center justify-center rounded-full", Platform.OS === "ios" ? "size-[28px]" : "size-[44px]", )} > <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" /> {hasLoadingEnvironments ? ( <View pointerEvents="none" className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500" /> ) : null} </Pressable> </ControlPillMenu> ) : null, [ showEnvironmentFilter, environmentActions, selectEnvironment, filterAccessibilityLabel, filterIcon, hasLoadingEnvironments, ], ); useLayoutEffect(() => { if (Platform.OS === "ios") { navigation.setOptions({ headerRight: () => environmentFilter }); } }, [navigation, environmentFilter]); return ( <View collapsable={false} className="flex-1 bg-sheet"> {Platform.OS === "android" ? ( <> <NativeStackScreenOptions options={{ headerShown: false }} /> <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} trailing={environmentFilter} /> </> ) : null} <ScrollView contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} refreshControl={ <RefreshControl refreshing={showingLimits ? limits.refreshing : refreshingUsage} onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} /> } > <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> <Animated.View key={tab} entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)} className="gap-6" > {showingLimits ? ( <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} selectedEnvironmentIds={selectedEnvironmentIds} /> ) : ( <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} <View className="flex-row items-center gap-3"> <SegmentedControl options={WINDOW_OPTIONS} selected={windowDays} onSelect={selectWindow} size="compact" className="flex-1" /> <SegmentedControl options={METRIC_OPTIONS} selected={metric} onSelect={setMetric} size="compact" className="w-36" /> </View> {merged.duplicateSources.length > 0 ? ( <Text className="text-sm text-foreground-muted"> Counted once across environments sharing a transcript directory:{" "} {merged.duplicateSources.join(", ")} </Text> ) : null} {isPending ? ( <Text className="py-16 text-center text-base text-foreground-muted"> Scanning provider transcripts… </Text> ) : selectedEnvironments.length === 0 ? ( <Text className="py-16 text-center text-base text-foreground-muted"> {environments.length === 0 ? "Connect an environment to see usage." : "Select an environment to see usage."} </Text> ) : ( <> <ChartCard merged={merged} days={chartDays} daily={chartTotals} metric={metric} sinceDay={window.sinceDay} untilDay={window.untilDay} isPast24Hours={isPast24Hours} timeZone={window.timeZone} /> <ProviderSection merged={merged} metric={metric} /> <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> <ModelsSection merged={merged} /> </> )} </> )} </Animated.View> </ScrollView> </View> ); } function SegmentedControl<Value extends number | string>(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 ( <View accessible={false} className={cn( "flex-row overflow-hidden rounded-full border-continuous bg-card", props.className, )} > <Animated.View pointerEvents="none" layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" style={{ width: `${100 / props.options.length}%`, start: `${ (Math.max( 0, props.options.findIndex((option) => option.value === props.selected), ) * 100) / props.options.length }%`, }} /> {props.options.map((option) => { const active = option.value === props.selected; return ( <Pressable key={String(option.value)} accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")} accessibilityLabel={option.accessibilityLabel ?? option.label} accessibilityState={{ selected: active }} onPress={() => props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", )} > <Text className={cn( compact ? "text-xs" : "text-sm", active ? "font-t3-medium text-foreground" : "text-foreground-muted", )} > {option.label} </Text> </Pressable> ); })} </View> ); } /** 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 ( <View className="gap-4 rounded-[24px] border-continuous bg-card p-4"> <View className="gap-0.5"> <Text className="text-sm text-foreground-muted"> {metric === "cost" ? "Raw token cost" : "Processed tokens"} </Text> <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} </Text> <Text className="text-sm text-foreground-muted"> {metric === "cost" ? "* if billed at full API rate" : `Across ${formatCount(merged.sessions)} sessions`} </Text> </View> {hasActivity ? ( <UsageDailyChart days={props.days} daily={props.daily} metric={metric} height={CHART_HEIGHT} /> ) : ( <View style={{ height: CHART_HEIGHT }} className="items-center justify-center"> <Text className="text-base text-foreground-muted">No activity in this window.</Text> </View> )} <View className="flex-row items-center justify-between"> <Text className="text-xs text-foreground-tertiary"> {props.isPast24Hours ? formatHourShort(props.days[0] ?? "", props.timeZone) : formatDayShort(props.sinceDay)} </Text> <View className="flex-row items-center gap-4"> {merged.providers.map((provider) => ( <View key={provider.provider} className="flex-row items-center gap-1.5"> <View className="size-2 rounded-full" style={{ backgroundColor: colors[provider.provider] }} /> <Text className="text-xs text-foreground-muted"> {PROVIDER_LABEL[provider.provider]} </Text> </View> ))} </View> <Text className="text-xs text-foreground-tertiary"> {props.isPast24Hours ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone) : formatDayShort(props.untilDay)} </Text> </View> </View> ); } 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 ( <SettingsSection title="Providers" card> {ordered.map((provider, index) => { const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( <View key={provider.provider} className={index === 0 ? "gap-2 p-4" : "gap-2 border-t border-border-subtle p-4"} > <View className="flex-row items-baseline justify-between gap-3"> <View className="flex-row items-center gap-2"> <View className="size-2.5 rounded-full" style={{ backgroundColor: colors[provider.provider] }} /> <Text className="text-lg text-foreground">{PROVIDER_LABEL[provider.provider]}</Text> </View> <Text className="text-lg tabular-nums text-foreground"> {metric === "cost" ? formatUsd(provider.costUsd) : formatTokens(provider.totalTokens)} </Text>