Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 141 additions & 41 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import {
enumerateDays,
enumerateHourStarts,
formatCount,
formatCoverageTime,
formatDateTimeShort,
formatDayShort,
formatHourShort,
formatPercent,
formatTokens,
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useMemo, 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";
Expand All @@ -26,7 +28,7 @@ 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 { type EnvironmentUsageStatus, useUsage } from "../../state/usage";
import { SettingsSection } from "../settings/components/SettingsSection";
import { UsageDailyChart } from "./UsageDailyChart";
import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
Expand Down Expand Up @@ -77,23 +79,44 @@ export function UsageRouteScreen() {
const isPast24Hours = windowDays === 1;
const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
useState<ReadonlySet<EnvironmentId> | null>(null);
const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage(
window,
selectedEnvironmentIds,
);
const {
merged,
environments,
selectedEnvironments,
isPending,
isPartial,
isRefreshing,
refreshError,
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 chartDays = useMemo(() => {
if (isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined) {
const untilTime =
merged.availableThroughTime !== null && merged.availableThroughTime < window.untilTime
? merged.availableThroughTime
: window.untilTime;
return enumerateHourStarts(window.sinceTime, untilTime);
Comment thread
saphid marked this conversation as resolved.
}
if (merged.availableThroughDay !== null && merged.availableThroughDay < window.untilDay) {
return enumerateDays(window.sinceDay, merged.availableThroughDay);
}
return days;
}, [
days,
isPast24Hours,
merged.availableThroughDay,
merged.availableThroughTime,
window.sinceDay,
window.sinceTime,
window.untilDay,
window.untilTime,
]);
const chartTotals = useMemo(
(): readonly DailyTotals[] =>
isPast24Hours
Expand All @@ -107,8 +130,6 @@ export function UsageRouteScreen() {
[isPast24Hours, merged.daily, merged.hourly],
);

const [refreshingUsage, setRefreshingUsage] = useState(false);
const refreshingRef = useRef(false);
const showingLimits = tab === "limits";
const selectWindow = (days: number) => {
setWindowSelection({
Expand All @@ -117,22 +138,10 @@ export function UsageRouteScreen() {
});
};
const refreshWindow = () => {
if (refreshingRef.current) return;
if (isRefreshing) 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);
});
setWindowSelection({ days: windowDays, window: nextWindow });
void refresh(nextWindow);
};

const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;
Expand Down Expand Up @@ -240,7 +249,7 @@ export function UsageRouteScreen() {
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
refreshControl={
<RefreshControl
refreshing={showingLimits ? limits.refreshing : refreshingUsage}
refreshing={showingLimits ? limits.refreshing : isRefreshing}
onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
/>
}
Expand All @@ -260,6 +269,14 @@ export function UsageRouteScreen() {
/>
) : (
<>
<UsageCoverageNotice
environments={selectedEnvironments}
merged={merged}
isPartial={isPartial}
isRefreshing={isRefreshing}
refreshError={refreshError}
timeZone={window.timeZone}
/>
{/* 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">
Expand All @@ -278,12 +295,6 @@ export function UsageRouteScreen() {
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…
Expand Down Expand Up @@ -408,14 +419,14 @@ function ChartCard(props: {
<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"}
{metric === "cost" ? "Local public-list estimate" : "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"
? "* estimated from local transcripts at public list rates"
: `Across ${formatCount(merged.sessions)} sessions`}
</Text>
</View>
Expand Down Expand Up @@ -455,7 +466,7 @@ function ChartCard(props: {
<Text className="text-xs text-foreground-tertiary">
{props.isPast24Hours
? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone)
: formatDayShort(props.untilDay)}
: formatDayShort(props.days[props.days.length - 1] ?? props.untilDay)}
</Text>
</View>
</View>
Expand Down Expand Up @@ -552,7 +563,16 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
<MetricCell
label="Uncached input"
value={formatTokens(merged.uncachedInputTokens)}
detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
detail="fresh input tokens"
/>
<MetricCell
label="Cache writes, estimated"
value={
merged.costQuality.cacheWriteUsd === null
? "Unavailable"
: formatUsd(merged.costQuality.cacheWriteUsd)
}
detail={`${formatTokens(merged.cacheCreationTokens)} tokens · not an expiry measure`}
/>
<MetricCell
label="Output"
Expand Down Expand Up @@ -646,3 +666,83 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
return environment.summary ? "Updating usage…" : "Loading usage…";
return "Usage up to date";
}

function UsageCoverageNotice(props: {
readonly environments: readonly EnvironmentUsageStatus[];
readonly merged: MergedUsage;
readonly isPartial: boolean;
readonly isRefreshing: boolean;
readonly refreshError: string | null | undefined;
readonly timeZone: string;
}) {
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;
const hasCoverage =
props.merged.availableThroughDay !== null || props.merged.availableThroughTime !== null;
if (
failed.length === 0 &&
stale.length === 0 &&
duplicateSources.length === 0 &&
!props.isPartial &&
!props.isRefreshing &&
!props.refreshError &&
!hasCoverage
Comment thread
saphid marked this conversation as resolved.
) {
return null;
}

return (
<View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3">
{props.merged.availableThroughTime !== null ? (
<Text className="text-sm text-foreground-muted">
Data available through{" "}
{formatCoverageTime(props.merged.availableThroughTime, props.timeZone)}.
</Text>
) : props.merged.availableThroughDay !== null ? (
<Text className="text-sm text-foreground-muted">
Data available through {formatDayShort(props.merged.availableThroughDay)}.
</Text>
) : null}
{props.isPartial ? (
<Text className="text-sm text-foreground-muted">
Some environments are still reporting. Totals are partial.
</Text>
) : null}
{props.isRefreshing ? (
<Text className="text-sm text-foreground-muted">Refreshing usage in the background.</Text>
) : null}
{props.refreshError ? (
<Text className="text-sm text-foreground-muted">{props.refreshError}</Text>
) : null}
{props.merged.lastUpdatedAt !== null ? (
<Text className="text-sm text-foreground-muted">
Last updated {formatDateTimeShort(props.merged.lastUpdatedAt, props.timeZone)}.
</Text>
) : null}
{!props.merged.sessionsExact ? (
<Text className="text-sm text-foreground-muted">
Sessions are unavailable until all environments share a cutoff.
</Text>
) : null}
{failed.map((environment) => (
<Text key={environment.environmentId} className="text-sm text-foreground-muted">
{environment.label} could not report usage.
</Text>
))}
{stale.map((environment) => (
<Text key={environment.environmentId} className="text-sm text-foreground-muted">
{environment.label} runs an older server version and is excluded from totals.
</Text>
))}
{duplicateSources.length > 0 ? (
<Text className="text-sm text-foreground-muted">
Counted once across environments sharing a transcript directory:{" "}
{duplicateSources.join(", ")}
</Text>
) : null}
</View>
);
}
Loading
Loading