535 lines · 19.3 KB · TypeScript
RawDownload
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<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 { 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 (    <View collapsable={false} className="flex-1 bg-sheet">      {Platform.OS === "android" ? (        <>          <NativeStackScreenOptions options={{ headerShown: false }} />          <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} />        </>      ) : null}      <ScrollView        // Remount at each tab's native top. Scrolling to y: 0 ignores iOS's        // automatic header inset and hides the tab bar under the header.        key={tab}        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" />         {showingLimits ? (          <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} />        ) : (          <>            {/* 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>            <UsageCoverageNotice              environments={environments}              merged={merged}              isPartial={isPartial}            />            {isPending ? (              <Text className="py-16 text-center text-base text-foreground-muted">                Scanning provider transcripts…              </Text>            ) : environments.length === 0 ? (              <Text className="py-16 text-center text-base text-foreground-muted">                Connect 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} />              </>            )}          </>        )}      </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      accessibilityRole={props.role === "tab" ? "tablist" : undefined}      className={cn(        "flex-row overflow-hidden rounded-full border-continuous bg-card",        props.className,      )}    >      {props.options.map((option) => {        const active = option.value === props.selected;        return (          <Pressable            key={String(option.value)}            accessibilityRole={props.role ?? "button"}            accessibilityLabel={option.accessibilityLabel}            accessibilityState={{ selected: active }}            onPress={() => props.onSelect(option.value)}            className={cn(              "flex-1 items-center justify-center rounded-full",              compact ? "h-9" : "h-11",              active && "bg-subtle-strong",            )}          >            <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>            </View>            <View className="h-1 flex-row overflow-hidden rounded-full bg-subtle">              <View                className="h-full rounded-full"                style={{ flex: share, backgroundColor: colors[provider.provider] }}              />              <View style={{ flex: 1 - share }} />            </View>            <Text className="text-sm text-foreground-muted">              {metric === "cost"                ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`                : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}            </Text>          </View>        );      })}    </SettingsSection>  );} 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 (    <SettingsSection title="Totals" card>      <View className="flex-row flex-wrap">        <MetricCell          label="Processed tokens"          value={formatTokens(merged.totalTokens)}          detail={`${formatTokens(periodAverage)} per active ${props.isPast24Hours ? "hour" : "day"}`}        />        <MetricCell          label="Cache savings"          value={formatUsd(merged.costQuality.cacheSavingsUsd)}          detail={            merged.costUsd > 0              ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost`              : "vs full input rates"          }        />        <MetricCell          label="Cached input"          value={formatTokens(merged.cachedInputTokens)}          detail={`${formatPercent(cachedShare)} of observed input`}        />        <MetricCell          label="Uncached input"          value={formatTokens(merged.uncachedInputTokens)}          detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}        />        <MetricCell          label="Output"          value={formatTokens(merged.outputTokens)}          detail={`incl. ${formatTokens(merged.reasoningTokens)} reasoning`}        />        <MetricCell          label="Unpriced"          value={formatPercent(merged.costQuality.unpricedShare)}          detail="of records, excluded from cost"        />      </View>    </SettingsSection>  );} function MetricCell(props: {  readonly label: string;  readonly value: string;  readonly detail: string;}) {  return (    <View className="w-1/2 gap-0.5 p-4">      <Text className="text-sm text-foreground-muted">{props.label}</Text>      <Text className="text-xl font-t3-medium tabular-nums text-foreground">{props.value}</Text>      <Text className="text-xs text-foreground-tertiary">{props.detail}</Text>    </View>  );} function ModelsSection(props: { readonly merged: MergedUsage }) {  const { merged } = props;  const colors = useProviderColors();  if (merged.models.length === 0) return null;   return (    <SettingsSection title="By model" card>      {merged.models.map((model, index) => (        <View          key={`${model.provider}:${model.model}`}          className={            index === 0              ? "flex-row items-center gap-3 p-4"              : "flex-row items-center gap-3 border-t border-border-subtle p-4"          }        >          <View            className="size-2.5 shrink-0 rounded-full"            style={{ backgroundColor: colors[model.provider] }}          />          <View className="min-w-0 flex-1 gap-0.5">            <Text className="text-base text-foreground" numberOfLines={1}>              {model.model}            </Text>            <Text className="text-sm text-foreground-muted">              {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens            </Text>          </View>          <Text className="text-base tabular-nums text-foreground">{formatUsd(model.costUsd)}</Text>        </View>      ))}    </SettingsSection>  );} /** * 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;

Lines 1–500 of 535.