authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-09 12:10:27+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-09 12:10:27+02:00
log1a003e383ac6b10258b8100c2617d938c4f06c69
tree4d8a6d434210f59f509fa86124130436eb8ac2d3
parent963ebf5bd7cce00d40ff60c258b34c12dcab271e
signature Signed by PGP key B5690EEEBB952194

Add cross-platform mobile usage dashboard (#5743)


20 files changed, 1475 insertions(+), 723 deletions(-)

apps/mobile/src/Stack.tsx+8
......@@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
4848import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
4949import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
5050import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
51import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
5152import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
5253import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
5354import {
......@@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({
190191 title: "Client Storage",
191192 },
192193 }),
194 SettingsUsage: createNativeStackScreen({
195 screen: UsageRouteScreen,
196 linking: "usage",
197 options: {
198 title: "Usage",
199 },
200 }),
193201 SettingsAuth: createNativeStackScreen({
194202 screen: SettingsAuthRouteScreen,
195203 linking: "auth",
apps/mobile/src/components/AppSymbol.tsx+2
......@@ -14,6 +14,7 @@ import {
1414 IconBellRinging,
1515 IconBolt,
1616 IconCamera,
17 IconChartBar,
1718 IconCheck,
1819 IconChevronDown,
1920 IconCode,
......@@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
9798 "bolt.circle": IconBolt,
9899 "bolt.horizontal.circle": IconBolt,
99100 camera: IconCamera,
101 "chart.bar.xaxis": IconChartBar,
100102 checkmark: IconCheck,
101103 "checkmark.circle": IconCircleCheck,
102104 clock: IconClock,
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1
......@@ -533,6 +533,7 @@ function GeneralSettingsSection() {
533533 return (
534534 <SettingsSection title="General">
535535 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
536 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
536537 </SettingsSection>
537538 );
538539}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+2-1
......@@ -3,6 +3,7 @@ export type SettingsSheetTarget =
33 | "SettingsArchive"
44 | "SettingsAppearance"
55 | "SettingsProjectGrouping"
6 | "SettingsClientStorage";
6 | "SettingsClientStorage"
7 | "SettingsUsage";
78
89export type SettingsLegalDocumentTarget = "SettingsLegal";
apps/mobile/src/features/usage/UsageDailyChart.ios.tsx created+50
......@@ -0,0 +1,50 @@
1import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui";
2import { frame } from "@expo/ui/swift-ui/modifiers";
3import { useMemo } from "react";
4
5import type { DailyTotals } from "@t3tools/shared/usageMerge";
6
7import { buildChartDays, type UsageChartMetric } from "./usageChartData";
8import { useProviderColors } from "./usageProviders";
9
10export interface UsageDailyChartProps {
11 readonly days: readonly string[];
12 readonly daily: readonly DailyTotals[];
13 readonly metric: UsageChartMetric;
14 readonly height: number;
15}
16
17/**
18 * Native Swift Charts daily bars. Points sharing an x value stack, so emitting
19 * one point per provider per day yields per-provider bands whose stack height
20 * is the day's total; changes animate natively.
21 *
22 * Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the
23 * screen renders its own edge labels under the chart instead.
24 */
25export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
26 const colors = useProviderColors();
27
28 const data = useMemo((): ChartDataPoint[] => {
29 return buildChartDays(days, daily, metric).flatMap((day) =>
30 day.values.map((entry) => ({
31 x: day.day,
32 y: entry.value,
33 color: colors[entry.provider],
34 })),
35 );
36 }, [days, daily, metric, colors]);
37
38 return (
39 <Host style={{ height, width: "100%" }}>
40 <Chart
41 type="bar"
42 data={data}
43 animate
44 showGrid={false}
45 barStyle={{ cornerRadius: 2 }}
46 modifiers={[frame({ height })]}
47 />
48 </Host>
49 );
50}
apps/mobile/src/features/usage/UsageDailyChart.tsx created+44
......@@ -0,0 +1,44 @@
1import { useMemo } from "react";
2import { View } from "react-native";
3
4import type { DailyTotals } from "@t3tools/shared/usageMerge";
5
6import { buildChartDays, type UsageChartMetric } from "./usageChartData";
7import { useProviderColors } from "./usageProviders";
8
9export interface UsageDailyChartProps {
10 readonly days: readonly string[];
11 readonly daily: readonly DailyTotals[];
12 readonly metric: UsageChartMetric;
13 readonly height: number;
14}
15
16/**
17 * Stacked daily bars drawn with plain views. Android and any platform without
18 * Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead.
19 */
20export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
21 const colors = useProviderColors();
22 const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]);
23 const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0);
24
25 return (
26 <View style={{ height }} className="flex-row items-end gap-px">
27 {/* column-reverse stacks the bottom-first provider values upward
28 without reversing the array (Hermes lacks Array#toReversed). */}
29 {chartDays.map((day) => (
30 <View key={day.day} className="h-full flex-1 flex-col-reverse overflow-hidden rounded-sm">
31 {day.values.map((entry) => (
32 <View
33 key={entry.provider}
34 style={{
35 height: max === 0 ? 0 : (entry.value / max) * height,
36 backgroundColor: colors[entry.provider],
37 }}
38 />
39 ))}
40 </View>
41 ))}
42 </View>
43 );
44}
apps/mobile/src/features/usage/UsageRouteScreen.tsx created+442
......@@ -0,0 +1,442 @@
1import { useNavigation } from "@react-navigation/native";
2import type { MergedUsage } from "@t3tools/shared/usageMerge";
3import {
4 enumerateDays,
5 formatCount,
6 formatDayShort,
7 formatPercent,
8 formatTokens,
9 formatUsd,
10 makeWindow,
11} from "@t3tools/shared/usageFormat";
12import { useMemo, useState } from "react";
13import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15
16import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
17import { AppText as Text } from "../../components/AppText";
18import { NativeStackScreenOptions } from "../../native/StackHeader";
19import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
20import { SettingsSection } from "../settings/components/SettingsSection";
21import { UsageDailyChart } from "./UsageDailyChart";
22import type { UsageChartMetric } from "./usageChartData";
23import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
24
25const WINDOW_OPTIONS = [
26 { days: 7, label: "7 days" },
27 { days: 30, label: "30 days" },
28 { days: 90, label: "90 days" },
29] as const;
30
31const CHART_HEIGHT = 180;
32
33export function UsageRouteScreen() {
34 const navigation = useNavigation();
35 const insets = useSafeAreaInsets();
36 const [windowDays, setWindowDays] = useState<number>(30);
37 const [metric, setMetric] = useState<UsageChartMetric>("cost");
38
39 // Recomputed only when the window length changes, so a re-render does not
40 // shift the range and refetch every environment.
41 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
42 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
43
44 const days = useMemo(
45 () => enumerateDays(window.sinceDay, window.untilDay),
46 [window.sinceDay, window.untilDay],
47 );
48
49 // The pull spinner tracks re-scans of environments that have answered
50 // before. The initial scan renders its own placeholder, and an unreachable
51 // environment stays pending forever — neither may pin the spinner on.
52 const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null);
53
54 return (
55 <View collapsable={false} className="flex-1 bg-sheet">
56 {Platform.OS === "android" ? (
57 <>
58 <NativeStackScreenOptions options={{ headerShown: false }} />
59 <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} />
60 </>
61 ) : null}
62 <ScrollView
63 contentInsetAdjustmentBehavior="automatic"
64 showsVerticalScrollIndicator={false}
65 className="flex-1"
66 contentContainerClassName="gap-6 px-5 pt-4"
67 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
68 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
69 >
70 <SegmentedControl
71 options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))}
72 selected={windowDays}
73 onSelect={setWindowDays}
74 />
75
76 <UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} />
77
78 {isPending ? (
79 <Text className="py-16 text-center text-base text-foreground-muted">
80 Scanning provider transcripts…
81 </Text>
82 ) : environments.length === 0 ? (
83 <Text className="py-16 text-center text-base text-foreground-muted">
84 Connect an environment to see usage.
85 </Text>
86 ) : (
87 <>
88 <ChartCard
89 merged={merged}
90 days={days}
91 metric={metric}
92 onMetricChange={setMetric}
93 sinceDay={window.sinceDay}
94 untilDay={window.untilDay}
95 />
96 <ProviderSection merged={merged} metric={metric} />
97 <TotalsSection merged={merged} />
98 <ModelsSection merged={merged} />
99 </>
100 )}
101 </ScrollView>
102 </View>
103 );
104}
105
106function SegmentedControl<Value extends number | string>(props: {
107 readonly options: readonly { readonly value: Value; readonly label: string }[];
108 readonly selected: Value;
109 readonly onSelect: (value: Value) => void;
110}) {
111 return (
112 <View className="flex-row overflow-hidden rounded-full border-continuous bg-card">
113 {props.options.map((option) => {
114 const active = option.value === props.selected;
115 return (
116 <Pressable
117 key={String(option.value)}
118 accessibilityRole="button"
119 accessibilityState={{ selected: active }}
120 onPress={() => props.onSelect(option.value)}
121 className={
122 active
123 ? "flex-1 items-center rounded-full bg-subtle-strong py-2"
124 : "flex-1 items-center py-2"
125 }
126 >
127 <Text
128 className={
129 active ? "text-sm font-t3-medium text-foreground" : "text-sm text-foreground-muted"
130 }
131 >
132 {option.label}
133 </Text>
134 </Pressable>
135 );
136 })}
137 </View>
138 );
139}
140
141/** Headline figure, the animated daily chart, and its legend, in one card. */
142function ChartCard(props: {
143 readonly merged: MergedUsage;
144 readonly days: readonly string[];
145 readonly metric: UsageChartMetric;
146 readonly onMetricChange: (metric: UsageChartMetric) => void;
147 readonly sinceDay: string;
148 readonly untilDay: string;
149}) {
150 const { merged, metric } = props;
151 const colors = useProviderColors();
152 const hasActivity = merged.daily.some((day) => day.totalTokens > 0);
153
154 return (
155 <View className="gap-4 rounded-[24px] border-continuous bg-card p-4">
156 <View className="flex-row items-start justify-between gap-3">
157 <View className="min-w-0 flex-1 gap-0.5">
158 <Text className="text-sm text-foreground-muted">
159 {metric === "cost" ? "Raw token cost" : "Processed tokens"}
160 </Text>
161 <Text className="text-4xl font-t3-bold tabular-nums text-foreground">
162 {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)}
163 </Text>
164 <Text className="text-sm text-foreground-muted">
165 {metric === "cost"
166 ? "* if billed at full API rate"
167 : `Across ${formatCount(merged.sessions)} sessions`}
168 </Text>
169 </View>
170 <MetricToggle metric={metric} onChange={props.onMetricChange} />
171 </View>
172
173 {hasActivity ? (
174 <UsageDailyChart
175 days={props.days}
176 daily={merged.daily}
177 metric={metric}
178 height={CHART_HEIGHT}
179 />
180 ) : (
181 <View style={{ height: CHART_HEIGHT }} className="items-center justify-center">
182 <Text className="text-base text-foreground-muted">No activity in this window.</Text>
183 </View>
184 )}
185
186 <View className="flex-row items-center justify-between">
187 <Text className="text-xs text-foreground-tertiary">{formatDayShort(props.sinceDay)}</Text>
188 <View className="flex-row items-center gap-4">
189 {merged.providers.map((provider) => (
190 <View key={provider.provider} className="flex-row items-center gap-1.5">
191 <View
192 className="size-2 rounded-full"
193 style={{ backgroundColor: colors[provider.provider] }}
194 />
195 <Text className="text-xs text-foreground-muted">
196 {PROVIDER_LABEL[provider.provider]}
197 </Text>
198 </View>
199 ))}
200 </View>
201 <Text className="text-xs text-foreground-tertiary">{formatDayShort(props.untilDay)}</Text>
202 </View>
203 </View>
204 );
205}
206
207function MetricToggle(props: {
208 readonly metric: UsageChartMetric;
209 readonly onChange: (metric: UsageChartMetric) => void;
210}) {
211 return (
212 <View className="flex-row overflow-hidden rounded-full bg-subtle">
213 {(["cost", "tokens"] as const).map((option) => {
214 const active = option === props.metric;
215 return (
216 <Pressable
217 key={option}
218 accessibilityRole="button"
219 accessibilityState={{ selected: active }}
220 onPress={() => props.onChange(option)}
221 className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"}
222 >
223 <Text
224 className={
225 active
226 ? "text-xs font-t3-medium uppercase text-foreground"
227 : "text-xs uppercase text-foreground-muted"
228 }
229 >
230 {option}
231 </Text>
232 </Pressable>
233 );
234 })}
235 </View>
236 );
237}
238
239function ProviderSection(props: {
240 readonly merged: MergedUsage;
241 readonly metric: UsageChartMetric;
242}) {
243 const { merged, metric } = props;
244 const colors = useProviderColors();
245 if (merged.providers.length === 0) return null;
246
247 // Ranked by whatever the toggle is showing, so the rows always descend.
248 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.
249 const ordered = [...merged.providers].sort((a, b) =>
250 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
251 );
252
253 return (
254 <SettingsSection title="Providers" card>
255 {ordered.map((provider, index) => {
256 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
257 return (
258 <View
259 key={provider.provider}
260 className={index === 0 ? "gap-2 p-4" : "gap-2 border-t border-border-subtle p-4"}
261 >
262 <View className="flex-row items-baseline justify-between gap-3">
263 <View className="flex-row items-center gap-2">
264 <View
265 className="size-2.5 rounded-full"
266 style={{ backgroundColor: colors[provider.provider] }}
267 />
268 <Text className="text-lg text-foreground">{PROVIDER_LABEL[provider.provider]}</Text>
269 </View>
270 <Text className="text-lg tabular-nums text-foreground">
271 {metric === "cost"
272 ? formatUsd(provider.costUsd)
273 : formatTokens(provider.totalTokens)}
274 </Text>
275 </View>
276 <View className="h-1 flex-row overflow-hidden rounded-full bg-subtle">
277 <View
278 className="h-full rounded-full"
279 style={{ flex: share, backgroundColor: colors[provider.provider] }}
280 />
281 <View style={{ flex: 1 - share }} />
282 </View>
283 <Text className="text-sm text-foreground-muted">
284 {metric === "cost"
285 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
286 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
287 </Text>
288 </View>
289 );
290 })}
291 </SettingsSection>
292 );
293}
294
295function TotalsSection(props: { readonly merged: MergedUsage }) {
296 const { merged } = props;
297 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;
298 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;
299 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
300 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
301
302 return (
303 <SettingsSection title="Totals" card>
304 <View className="flex-row flex-wrap">
305 <MetricCell
306 label="Processed tokens"
307 value={formatTokens(merged.totalTokens)}
308 detail={`${formatTokens(dailyAverage)} per active day`}
309 />
310 <MetricCell
311 label="Cache savings"
312 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
313 detail={
314 merged.costUsd > 0
315 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost`
316 : "vs full input rates"
317 }
318 />
319 <MetricCell
320 label="Cached input"
321 value={formatTokens(merged.cachedInputTokens)}
322 detail={`${formatPercent(cachedShare)} of observed input`}
323 />
324 <MetricCell
325 label="Uncached input"
326 value={formatTokens(merged.uncachedInputTokens)}
327 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
328 />
329 <MetricCell
330 label="Output"
331 value={formatTokens(merged.outputTokens)}
332 detail={`incl. ${formatTokens(merged.reasoningTokens)} reasoning`}
333 />
334 <MetricCell
335 label="Unpriced"
336 value={formatPercent(merged.costQuality.unpricedShare)}
337 detail="of records, excluded from cost"
338 />
339 </View>
340 </SettingsSection>
341 );
342}
343
344function MetricCell(props: {
345 readonly label: string;
346 readonly value: string;
347 readonly detail: string;
348}) {
349 return (
350 <View className="w-1/2 gap-0.5 p-4">
351 <Text className="text-sm text-foreground-muted">{props.label}</Text>
352 <Text className="text-xl font-t3-medium tabular-nums text-foreground">{props.value}</Text>
353 <Text className="text-xs text-foreground-tertiary">{props.detail}</Text>
354 </View>
355 );
356}
357
358function ModelsSection(props: { readonly merged: MergedUsage }) {
359 const { merged } = props;
360 const colors = useProviderColors();
361 if (merged.models.length === 0) return null;
362
363 return (
364 <SettingsSection title="By model" card>
365 {merged.models.map((model, index) => (
366 <View
367 key={`${model.provider}:${model.model}`}
368 className={
369 index === 0
370 ? "flex-row items-center gap-3 p-4"
371 : "flex-row items-center gap-3 border-t border-border-subtle p-4"
372 }
373 >
374 <View
375 className="size-2.5 shrink-0 rounded-full"
376 style={{ backgroundColor: colors[model.provider] }}
377 />
378 <View className="min-w-0 flex-1 gap-0.5">
379 <Text className="text-base text-foreground" numberOfLines={1}>
380 {model.model}
381 </Text>
382 <Text className="text-sm text-foreground-muted">
383 {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens
384 </Text>
385 </View>
386 <Text className="text-base tabular-nums text-foreground">{formatUsd(model.costUsd)}</Text>
387 </View>
388 ))}
389 </SettingsSection>
390 );
391}
392
393/**
394 * Says plainly when the totals are incomplete: an environment still answering,
395 * one that failed, or one whose transcripts another environment already
396 * reported.
397 */
398function UsageCoverageNotice(props: {
399 readonly environments: readonly EnvironmentUsageStatus[];
400 readonly merged: MergedUsage;
401 readonly isPartial: boolean;
402}) {
403 const failed = props.environments.filter((environment) => environment.error !== null);
404 const stale = props.environments.filter((environment) =>
405 props.merged.staleEnvironments.includes(environment.environmentId),
406 );
407 const duplicateSources = props.merged.duplicateSources;
408 if (
409 failed.length === 0 &&
410 stale.length === 0 &&
411 duplicateSources.length === 0 &&
412 !props.isPartial
413 ) {
414 return null;
415 }
416
417 return (
418 <View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3">
419 {props.isPartial ? (
420 <Text className="text-sm text-foreground-muted">
421 Some environments are still reporting. Totals are partial.
422 </Text>
423 ) : null}
424 {failed.map((environment) => (
425 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
426 {environment.label} could not report usage.
427 </Text>
428 ))}
429 {stale.map((environment) => (
430 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
431 {environment.label} runs an older server version and is excluded from totals.
432 </Text>
433 ))}
434 {duplicateSources.length > 0 ? (
435 <Text className="text-sm text-foreground-muted">
436 Counted once across environments sharing a transcript directory:{" "}
437 {duplicateSources.join(", ")}
438 </Text>
439 ) : null}
440 </View>
441 );
442}
apps/mobile/src/features/usage/usageChartData.ts created+41
......@@ -0,0 +1,41 @@
1/**
2 * Shapes merged daily totals into the per-day provider stacks both chart
3 * implementations (Swift Charts on iOS, plain views elsewhere) render.
4 *
5 * @module usageChartData
6 */
7import type { UsageProviderKind } from "@t3tools/contracts";
8import type { DailyTotals } from "@t3tools/shared/usageMerge";
9
10import { PROVIDER_ORDER } from "./usageProviders";
11
12export type UsageChartMetric = "cost" | "tokens";
13
14export interface UsageChartDay {
15 readonly day: string;
16 /** In {@link PROVIDER_ORDER}, i.e. bottom of the stack first. */
17 readonly values: readonly { readonly provider: UsageProviderKind; readonly value: number }[];
18 readonly total: number;
19}
20
21/** One entry per day in the window, zero-filled where nothing happened. */
22export function buildChartDays(
23 days: readonly string[],
24 daily: readonly DailyTotals[],
25 metric: UsageChartMetric,
26): readonly UsageChartDay[] {
27 const byDay = new Map(daily.map((totals) => [totals.day, totals]));
28 return days.map((day) => {
29 const totals = byDay.get(day);
30 const values = PROVIDER_ORDER.map((provider) => {
31 const entry = totals?.byProvider.get(provider);
32 const value = entry === undefined ? 0 : metric === "cost" ? entry.costUsd : entry.totalTokens;
33 return { provider, value };
34 });
35 return {
36 day,
37 values,
38 total: values.reduce((sum, entry) => sum + entry.value, 0),
39 };
40 });
41}
apps/mobile/src/features/usage/usageProviders.ts created+25
......@@ -0,0 +1,25 @@
1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useColorScheme } from "react-native";
3
4/**
5 * Series and table order. The chart stacks providers from the bottom in this
6 * order, so it also fixes which band sits on top of the bars.
7 */
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
9
10export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
11 claude: "Claude Code",
12 codex: "Codex",
13};
14
15/**
16 * Claude's brand orange holds in both themes; Codex is neutral and must flip
17 * with the theme or its bars vanish against the matching background.
18 */
19export function useProviderColors(): Record<UsageProviderKind, string> {
20 const scheme = useColorScheme();
21 return {
22 claude: "#d97757",
23 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
24 };
25}
apps/mobile/src/state/usage.ts created+129
......@@ -0,0 +1,129 @@
1/**
2 * Multi-environment usage state.
3 *
4 * Every connected environment answers the same typed query; the client merges
5 * the results. Raw transcripts never leave the machine that produced them.
6 *
7 * Mirror of `apps/web/src/state/usage.ts` over mobile's atom wiring; the merge
8 * rules themselves live in `@t3tools/shared/usageMerge`.
9 *
10 * @module state/usage
11 */
12import { useAtomValue } from "@effect/atom-react";
13import {
14 USAGE_CONTRACT_VERSION,
15 type EnvironmentId,
16 type UsageSummary,
17 type UsageSummaryInput,
18} from "@t3tools/contracts";
19import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
20import * as Option from "effect/Option";
21import { AsyncResult, Atom } from "effect/unstable/reactivity";
22import { useCallback, useMemo } from "react";
23
24import { appAtomRegistry } from "./atom-registry";
25import { environmentPresentations } from "./presentation";
26import { serverEnvironment } from "./server";
27
28export interface EnvironmentUsageStatus {
29 readonly environmentId: EnvironmentId;
30 readonly label: string;
31 readonly isPending: boolean;
32 readonly error: string | null;
33 readonly summary: UsageSummary | null;
34}
35
36/**
37 * Reads every environment's summary for one window.
38 *
39 * Keyed by the serialised window so switching ranges does not thrash the atom
40 * cache, and so each environment's query is shared with any other reader of the
41 * same window.
42 */
43const usageByWindowAtom = Atom.family((windowKey: string) =>
44 Atom.make((get): readonly EnvironmentUsageStatus[] => {
45 const input = JSON.parse(windowKey) as UsageSummaryInput;
46 const presentations = get(environmentPresentations.presentationsAtom);
47
48 const statuses: EnvironmentUsageStatus[] = [];
49 for (const [environmentId, presentation] of presentations) {
50 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
51 statuses.push({
52 environmentId,
53 label: presentation.entry.target.label,
54 isPending: result.waiting,
55 error: result._tag === "Failure" ? "This environment could not report usage." : null,
56 summary: Option.getOrNull(AsyncResult.value(result)),
57 });
58 }
59 return statuses;
60 }).pipe(Atom.withLabel(`mobile-usage:window:${windowKey}`)),
61);
62
63export interface UsageView {
64 readonly merged: MergedUsage;
65 readonly environments: readonly EnvironmentUsageStatus[];
66 /** True until at least one environment has answered. */
67 readonly isPending: boolean;
68 /**
69 * True while environments that have not failed are still answering. Failed
70 * environments are reported through their own error rows: totals will not
71 * improve by waiting on them, so they must not read as "still reporting".
72 */
73 readonly isPartial: boolean;
74 readonly refresh: () => void;
75}
76
77export function useUsage(input: UsageSummaryInput): UsageView {
78 const windowKey = useMemo(
79 () =>
80 JSON.stringify({
81 sinceDay: input.sinceDay,
82 untilDay: input.untilDay,
83 timeZone: input.timeZone,
84 }),
85 [input.sinceDay, input.untilDay, input.timeZone],
86 );
87 const atom = usageByWindowAtom(windowKey);
88 const environments = useAtomValue(atom);
89
90 // Refreshing only the derived atom would re-read the per-environment SWR
91 // queries within their stale window and change nothing. Refresh each
92 // environment's query so pull-to-refresh always rescans.
93 const refresh = useCallback(() => {
94 const input = JSON.parse(windowKey) as UsageSummaryInput;
95 for (const environment of environments) {
96 appAtomRegistry.refresh(
97 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),
98 );
99 }
100 }, [environments, windowKey]);
101
102 const merged = useMemo(() => {
103 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
104 environment.summary === null
105 ? []
106 : [
107 {
108 environmentId: environment.environmentId,
109 label: environment.label,
110 summary: environment.summary,
111 },
112 ],
113 );
114 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
115 }, [environments]);
116
117 const answeredCount = environments.filter((environment) => environment.summary !== null).length;
118 const stillReporting = environments.filter(
119 (environment) => environment.summary === null && environment.error === null,
120 ).length;
121
122 return {
123 merged,
124 environments,
125 isPending: answeredCount === 0 && stillReporting > 0,
126 isPartial: answeredCount > 0 && stillReporting > 0,
127 refresh,
128 };
129}
apps/web/src/components/usage/UsagePage.tsx+1-1
......@@ -13,7 +13,7 @@ import {
1313 formatTokens,
1414 formatUsd,
1515 makeWindow,
16} from "../../usage/usageFormat";
16} from "@t3tools/shared/usageFormat";
1717import { ScrollArea } from "../ui/scroll-area";
1818import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
1919import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
apps/web/src/components/usage/UsageProviderChart.tsx+2-2
......@@ -1,8 +1,8 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
22import { useCallback, useMemo, useRef, useState } from "react";
33
4import type { DailyTotals } from "../../usage/usageMerge";
5import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat";
4import type { DailyTotals } from "@t3tools/shared/usageMerge";
5import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
66import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
77
88const VIEW_WIDTH = 960;
apps/web/src/state/usage.ts+1-1
......@@ -17,7 +17,7 @@ import * as Option from "effect/Option";
1717import { AsyncResult, Atom } from "effect/unstable/reactivity";
1818import { useCallback, useMemo } from "react";
1919
20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge";
20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
2121import { appAtomRegistry } from "../rpc/atomRegistry";
2222import { environmentPresentations } from "./presentation";
2323import { serverEnvironment } from "./server";
apps/web/src/usage/usageFormat.ts deleted-107
......@@ -1,107 +0,0 @@
1/**
2 * Display formatting for the usage page.
3 *
4 * @module usageFormat
5 */
6import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
7
8const CURRENCY = new Intl.NumberFormat("en-US", {
9 style: "currency",
10 currency: "USD",
11 minimumFractionDigits: 2,
12 maximumFractionDigits: 2,
13});
14
15const INTEGER = new Intl.NumberFormat("en-US");
16
17export function formatUsd(value: number): string {
18 return CURRENCY.format(value);
19}
20
21export function formatCount(value: number): string {
22 return INTEGER.format(Math.round(value));
23}
24
25/**
26 * Compacts a token count to three significant figures with a unit suffix, so
27 * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`).
28 */
29export function formatTokens(value: number): string {
30 const abs = Math.abs(value);
31 if (abs >= 1e12) return `${trim(value / 1e12)}T`;
32 if (abs >= 1e9) return `${trim(value / 1e9)}B`;
33 if (abs >= 1e6) return `${trim(value / 1e6)}M`;
34 if (abs >= 1e3) return `${trim(value / 1e3)}K`;
35 return INTEGER.format(Math.round(value));
36}
37
38function trim(value: number): string {
39 const abs = Math.abs(value);
40 const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
41 return value.toFixed(digits).replace(/\.0+$/, "");
42}
43
44export function formatPercent(share: number, digits = 1): string {
45 return `${(share * 100).toFixed(digits)}%`;
46}
47
48/** `2026-08-07` to `Aug 7`. */
49export function formatDayShort(day: string): string {
50 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
51 if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
52 const MONTHS = [
53 "Jan",
54 "Feb",
55 "Mar",
56 "Apr",
57 "May",
58 "Jun",
59 "Jul",
60 "Aug",
61 "Sep",
62 "Oct",
63 "Nov",
64 "Dec",
65 ];
66 return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
67}
68
69/** Inclusive day list between two `YYYY-MM-DD` bounds. */
70export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
71 const days: string[] = [];
72 const start = Date.parse(`${sinceDay}T00:00:00Z`);
73 const end = Date.parse(`${untilDay}T00:00:00Z`);
74 if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
75
76 for (let cursor = start; cursor <= end; cursor += 86_400_000) {
77 days.push(new Date(cursor).toISOString().slice(0, 10));
78 }
79 return days;
80}
81
82/**
83 * The window the page requests, expressed in the viewer's own time zone so days
84 * line up with what they actually experienced.
85 */
86export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
87 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
88 const format = new Intl.DateTimeFormat("en-CA", {
89 timeZone,
90 year: "numeric",
91 month: "2-digit",
92 day: "2-digit",
93 });
94 const untilDay = format.format(now);
95 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
96 // around a DST transition. Only "today" needs the zone; the window start is
97 // pure calendar arithmetic on that day, done in UTC where days are uniform.
98 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
99 .split("-")
100 .map((part) => Number.parseInt(part, 10));
101 const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1)));
102 return {
103 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
104 untilDay: UsageDay.make(untilDay),
105 timeZone,
106 };
107}
apps/web/src/usage/usageMerge.test.ts deleted-258
......@@ -1,258 +0,0 @@
1import {
2 USAGE_CONTRACT_VERSION,
3 type EnvironmentId,
4 type UsageBucket,
5 type UsageDay,
6 type UsageProviderKind,
7 type UsageSummary,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10
11import { mergeUsage, type EnvironmentUsage } from "./usageMerge";
12
13function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
14 return {
15 day: "2026-08-07" as UsageDay,
16 provider: "claude",
17 model: "claude-fable-5",
18 totals: {
19 uncachedInputTokens: 100,
20 cachedInputTokens: 1000,
21 cacheCreationTokens: 10,
22 outputTokens: 50,
23 reasoningTokens: 0,
24 },
25 costUsd: 10,
26 cacheSavingsUsd: 2,
27 costSource: "modelPriced",
28 records: 5,
29 unpricedRecords: 0,
30 sessions: 1,
31 ...overrides,
32 };
33}
34
35function summary(
36 buckets: readonly UsageBucket[],
37 sources: readonly {
38 provider: UsageProviderKind;
39 hostId: string;
40 homePath: string;
41 volumeId?: string;
42 distinctSessions?: number;
43 }[],
44 contractVersion: number = USAGE_CONTRACT_VERSION,
45): UsageSummary {
46 return {
47 contractVersion,
48 readAt: "2026-08-07T00:00:00.000Z",
49 timeZone: "UTC",
50 sinceDay: "2026-08-01" as UsageDay,
51 untilDay: "2026-08-31" as UsageDay,
52 buckets,
53 sources: sources.map((source) => ({
54 fingerprint: {
55 hostId: source.hostId,
56 provider: source.provider,
57 resolvedHomePath: source.homePath,
58 volumeId: source.volumeId ?? `vol-${source.hostId}`,
59 },
60 status: "ok" as const,
61 scannedFiles: 1,
62 skippedFiles: 0,
63 malformedRecords: 0,
64 distinctSessions: source.distinctSessions ?? 1,
65 message: null,
66 })),
67 pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
68 scanDurationMs: 1,
69 };
70}
71
72function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
73 return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
74}
75
76describe("mergeUsage", () => {
77 it("sums environments that read different transcript directories", () => {
78 const merged = mergeUsage(
79 [
80 environment(
81 "env-a",
82 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]),
83 ),
84 environment(
85 "env-b",
86 summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]),
87 ),
88 ],
89 USAGE_CONTRACT_VERSION,
90 );
91
92 expect(merged.costUsd).toBe(20);
93 expect(merged.records).toBe(10);
94 expect(merged.duplicateSources).toHaveLength(0);
95 });
96
97 it("counts a shared transcript directory once", () => {
98 // Two worktree servers on one machine resolve the same provider home.
99 const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
100 const merged = mergeUsage(
101 [
102 environment("env-a", summary([bucket()], [shared])),
103 environment("env-b", summary([bucket()], [shared])),
104 ],
105 USAGE_CONTRACT_VERSION,
106 );
107
108 expect(merged.costUsd).toBe(10);
109 expect(merged.records).toBe(5);
110 expect(merged.sessions).toBe(1);
111 expect(merged.duplicateSources).toHaveLength(1);
112 expect(merged.contributingEnvironments).toEqual(["env-a"]);
113 });
114
115 it("drops only the duplicated provider, keeping the environment's other one", () => {
116 const sharedClaude = {
117 provider: "claude" as const,
118 hostId: "mac",
119 homePath: "/home/theo/.claude",
120 };
121 const merged = mergeUsage(
122 [
123 environment("env-a", summary([bucket()], [sharedClaude])),
124 environment(
125 "env-b",
126 summary(
127 [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })],
128 [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }],
129 ),
130 ),
131 ],
132 USAGE_CONTRACT_VERSION,
133 );
134
135 // env-b's claude bucket is dropped, its codex bucket survives.
136 expect(merged.costUsd).toBe(14);
137 expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([
138 "claude",
139 "codex",
140 ]);
141 });
142
143 it("excludes an environment reporting an older contract version", () => {
144 const merged = mergeUsage(
145 [
146 environment(
147 "env-a",
148 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]),
149 ),
150 environment(
151 "env-b",
152 summary(
153 [bucket()],
154 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
155 USAGE_CONTRACT_VERSION - 1,
156 ),
157 ),
158 ],
159 USAGE_CONTRACT_VERSION,
160 );
161
162 expect(merged.costUsd).toBe(10);
163 expect(merged.staleEnvironments).toEqual(["env-b"]);
164 });
165
166 it("derives provider shares and cost quality", () => {
167 const merged = mergeUsage(
168 [
169 environment(
170 "env-a",
171 summary(
172 [
173 bucket({ costUsd: 75 }),
174 bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }),
175 ],
176 [
177 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
178 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
179 ],
180 ),
181 ),
182 ],
183 USAGE_CONTRACT_VERSION,
184 );
185
186 expect(merged.providers[0]?.provider).toBe("claude");
187 expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5);
188 expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5);
189 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
190 });
191
192 it("keeps two machines apart when hostname and home path collide", () => {
193 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
194 // one machine's usage vanish. Filesystem identity separates them.
195 const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" };
196 const merged = mergeUsage(
197 [
198 environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])),
199 environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])),
200 ],
201 USAGE_CONTRACT_VERSION,
202 );
203
204 expect(merged.costUsd).toBe(20);
205 expect(merged.duplicateSources).toHaveLength(0);
206 });
207
208 it("still collapses two servers reading the same directory", () => {
209 const same = {
210 provider: "claude" as const,
211 hostId: "mac",
212 homePath: "/Users/theo/.claude",
213 volumeId: "16777220:1234",
214 };
215 const merged = mergeUsage(
216 [
217 environment("env-a", summary([bucket()], [same])),
218 environment("env-b", summary([bucket()], [same])),
219 ],
220 USAGE_CONTRACT_VERSION,
221 );
222
223 expect(merged.costUsd).toBe(10);
224 expect(merged.duplicateSources).toHaveLength(1);
225 });
226
227 it("totals sessions from per-directory distinct counts, not per-bucket sums", () => {
228 // One session that spans two days appears in two buckets. Summing bucket
229 // sessions would say 2; the source's distinct count says 1.
230 const merged = mergeUsage(
231 [
232 environment(
233 "env-a",
234 summary(
235 [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })],
236 [
237 {
238 provider: "claude",
239 hostId: "mac",
240 homePath: "/a/.claude",
241 distinctSessions: 1,
242 },
243 ],
244 ),
245 ),
246 ],
247 USAGE_CONTRACT_VERSION,
248 );
249
250 expect(merged.sessions).toBe(1);
251 });
252
253 it("returns empty totals with no environments", () => {
254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
255 expect(merged.costUsd).toBe(0);
256 expect(merged.daily).toHaveLength(0);
257 });
258});
apps/web/src/usage/usageMerge.ts deleted-353
......@@ -1,353 +0,0 @@
1/**
2 * Merges per-environment usage summaries into the single view the page renders.
3 *
4 * Pure, so the de-duplication and derivation rules can be tested without a
5 * connected environment.
6 *
7 * @module usageMerge
8 */
9import type {
10 EnvironmentId,
11 UsageBucket,
12 UsageProviderKind,
13 UsageSourceFingerprint,
14 UsageSummary,
15} from "@t3tools/contracts";
16
17export interface EnvironmentUsage {
18 readonly environmentId: EnvironmentId;
19 readonly label: string;
20 readonly summary: UsageSummary;
21}
22
23export interface ProviderTotals {
24 readonly provider: UsageProviderKind;
25 readonly costUsd: number;
26 readonly totalTokens: number;
27 readonly records: number;
28 readonly costShare: number;
29 readonly tokenShare: number;
30}
31
32export interface ModelTotals {
33 readonly model: string;
34 readonly provider: UsageProviderKind;
35 readonly costUsd: number;
36 readonly totalTokens: number;
37 readonly records: number;
38 readonly costShare: number;
39}
40
41export interface DailyTotals {
42 readonly day: string;
43 readonly costUsd: number;
44 readonly totalTokens: number;
45 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
46}
47
48export interface CostQuality {
49 readonly providerReportedShare: number;
50 readonly modelPricedShare: number;
51 readonly unpricedShare: number;
52 readonly cacheSavingsUsd: number;
53}
54
55export interface MergedUsage {
56 readonly costUsd: number;
57 readonly uncachedInputTokens: number;
58 readonly cachedInputTokens: number;
59 readonly cacheCreationTokens: number;
60 readonly outputTokens: number;
61 readonly reasoningTokens: number;
62 readonly totalTokens: number;
63 readonly records: number;
64 readonly sessions: number;
65 readonly providers: readonly ProviderTotals[];
66 readonly models: readonly ModelTotals[];
67 readonly daily: readonly DailyTotals[];
68 readonly costQuality: CostQuality;
69 /** Environments whose data was dropped as a duplicate of another's. */
70 readonly duplicateSources: readonly string[];
71 readonly contributingEnvironments: readonly EnvironmentId[];
72 readonly staleEnvironments: readonly EnvironmentId[];
73}
74
75/**
76 * Two sources are the same physical transcript directory only when host,
77 * provider, path and filesystem identity all agree.
78 *
79 * `volumeId` is what stops two machines that happen to share a hostname and a
80 * home path, which is every Mac in a fleet, from collapsing into one source and
81 * having one of them silently dropped.
82 */
83function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
84 return [
85 fingerprint.hostId,
86 fingerprint.provider,
87 fingerprint.resolvedHomePath,
88 fingerprint.volumeId,
89 ].join(" ");
90}
91
92/**
93 * Decides which environment owns each physical transcript directory.
94 *
95 * Several environments on one machine (worktree servers, for instance) resolve
96 * the same provider home and would otherwise double count every token. The
97 * first environment in a stable order claims a fingerprint; the rest have that
98 * provider's buckets dropped. Environments are sorted by id so the winner does
99 * not change between renders.
100 */
101function claimSources(environments: readonly EnvironmentUsage[]): {
102 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
103 readonly duplicates: readonly string[];
104} {
105 const ownerByFingerprint = new Map<string, EnvironmentId>();
106 const duplicates: string[] = [];
107
108 const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
109
110 for (const environment of ordered) {
111 for (const source of environment.summary.sources) {
112 if (source.status === "missing") continue;
113 const key = fingerprintKey(source.fingerprint);
114 if (ownerByFingerprint.has(key)) {
115 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
116 continue;
117 }
118 ownerByFingerprint.set(key, environment.environmentId);
119 }
120 }
121
122 return { ownerByFingerprint, duplicates };
123}
124
125/** Sources this environment owns after fingerprint claims, plus their buckets. */
126function ownedContribution(
127 environment: EnvironmentUsage,
128 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
129): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
130 const ownedProviders = new Set<UsageProviderKind>();
131 let sessions = 0;
132 for (const source of environment.summary.sources) {
133 if (source.status === "missing") continue;
134 const key = fingerprintKey(source.fingerprint);
135 if (ownerByFingerprint.get(key) === environment.environmentId) {
136 ownedProviders.add(source.fingerprint.provider);
137 // Distinct within a directory. Summing per-bucket session counts instead
138 // would count a session once per day and model it spans.
139 sessions += source.distinctSessions;
140 }
141 }
142 return {
143 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
144 sessions,
145 };
146}
147
148function bucketTokens(bucket: UsageBucket): number {
149 // reasoningTokens is a subset of outputTokens and must not be added again.
150 return (
151 bucket.totals.uncachedInputTokens +
152 bucket.totals.cachedInputTokens +
153 bucket.totals.cacheCreationTokens +
154 bucket.totals.outputTokens
155 );
156}
157
158const EMPTY_MERGED: MergedUsage = {
159 costUsd: 0,
160 uncachedInputTokens: 0,
161 cachedInputTokens: 0,
162 cacheCreationTokens: 0,
163 outputTokens: 0,
164 reasoningTokens: 0,
165 totalTokens: 0,
166 records: 0,
167 sessions: 0,
168 providers: [],
169 models: [],
170 daily: [],
171 costQuality: {
172 providerReportedShare: 0,
173 modelPricedShare: 0,
174 unpricedShare: 0,
175 cacheSavingsUsd: 0,
176 },
177 duplicateSources: [],
178 contributingEnvironments: [],
179 staleEnvironments: [],
180};
181
182/**
183 * Merges every connected environment's summary.
184 *
185 * `expectedContractVersion` guards against an environment running older server
186 * code: rather than blocking the page, its data is excluded and its id is
187 * reported so the UI can say coverage is partial.
188 */
189export function mergeUsage(
190 environments: readonly EnvironmentUsage[],
191 expectedContractVersion: number,
192): MergedUsage {
193 if (environments.length === 0) return EMPTY_MERGED;
194
195 const current: EnvironmentUsage[] = [];
196 const staleEnvironments: EnvironmentId[] = [];
197 for (const environment of environments) {
198 if (environment.summary.contractVersion === expectedContractVersion) {
199 current.push(environment);
200 } else {
201 staleEnvironments.push(environment.environmentId);
202 }
203 }
204
205 const { ownerByFingerprint, duplicates } = claimSources(current);
206
207 let costUsd = 0;
208 let uncachedInputTokens = 0;
209 let cachedInputTokens = 0;
210 let cacheCreationTokens = 0;
211 let outputTokens = 0;
212 let reasoningTokens = 0;
213 let records = 0;
214 let sessions = 0;
215 let cacheSavingsUsd = 0;
216 let providerReportedRecords = 0;
217 let unpricedRecords = 0;
218
219 const providerAccumulator = new Map<
220 UsageProviderKind,
221 { costUsd: number; totalTokens: number; records: number }
222 >();
223 const modelAccumulator = new Map<
224 string,
225 { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }
226 >();
227 const dailyAccumulator = new Map<
228 string,
229 {
230 costUsd: number;
231 totalTokens: number;
232 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
233 }
234 >();
235 const contributingEnvironments: EnvironmentId[] = [];
236
237 for (const environment of current) {
238 const { buckets, sessions: environmentSessions } = ownedContribution(
239 environment,
240 ownerByFingerprint,
241 );
242 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
243 sessions += environmentSessions;
244
245 for (const bucket of buckets) {
246 const tokens = bucketTokens(bucket);
247
248 costUsd += bucket.costUsd;
249 cacheSavingsUsd += bucket.cacheSavingsUsd;
250 uncachedInputTokens += bucket.totals.uncachedInputTokens;
251 cachedInputTokens += bucket.totals.cachedInputTokens;
252 cacheCreationTokens += bucket.totals.cacheCreationTokens;
253 outputTokens += bucket.totals.outputTokens;
254 reasoningTokens += bucket.totals.reasoningTokens;
255 records += bucket.records;
256 unpricedRecords += bucket.unpricedRecords;
257 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
258
259 const provider = providerAccumulator.get(bucket.provider) ?? {
260 costUsd: 0,
261 totalTokens: 0,
262 records: 0,
263 };
264 provider.costUsd += bucket.costUsd;
265 provider.totalTokens += tokens;
266 provider.records += bucket.records;
267 providerAccumulator.set(bucket.provider, provider);
268
269 const modelKey = `${bucket.provider} ${bucket.model}`;
270 const model = modelAccumulator.get(modelKey) ?? {
271 provider: bucket.provider,
272 costUsd: 0,
273 totalTokens: 0,
274 records: 0,
275 };
276 model.costUsd += bucket.costUsd;
277 model.totalTokens += tokens;
278 model.records += bucket.records;
279 modelAccumulator.set(modelKey, model);
280
281 const day = dailyAccumulator.get(bucket.day) ?? {
282 costUsd: 0,
283 totalTokens: 0,
284 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
285 };
286 day.costUsd += bucket.costUsd;
287 day.totalTokens += tokens;
288 const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
289 dayProvider.costUsd += bucket.costUsd;
290 dayProvider.totalTokens += tokens;
291 day.byProvider.set(bucket.provider, dayProvider);
292 dailyAccumulator.set(bucket.day, day);
293 }
294 }
295
296 const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
297
298 const providers: ProviderTotals[] = [...providerAccumulator.entries()]
299 .map(([provider, totals]) => ({
300 provider,
301 costUsd: totals.costUsd,
302 totalTokens: totals.totalTokens,
303 records: totals.records,
304 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
305 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
306 }))
307 .sort((a, b) => b.costUsd - a.costUsd);
308
309 const models: ModelTotals[] = [...modelAccumulator.entries()]
310 .map(([key, totals]) => ({
311 model: key.slice(key.indexOf(" ") + 1),
312 provider: totals.provider,
313 costUsd: totals.costUsd,
314 totalTokens: totals.totalTokens,
315 records: totals.records,
316 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
317 }))
318 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
319
320 const daily: DailyTotals[] = [...dailyAccumulator.entries()]
321 .map(([day, totals]) => ({
322 day,
323 costUsd: totals.costUsd,
324 totalTokens: totals.totalTokens,
325 byProvider: totals.byProvider,
326 }))
327 .sort((a, b) => a.day.localeCompare(b.day));
328
329 return {
330 costUsd,
331 uncachedInputTokens,
332 cachedInputTokens,
333 cacheCreationTokens,
334 outputTokens,
335 reasoningTokens,
336 totalTokens,
337 records,
338 sessions,
339 providers,
340 models,
341 daily,
342 costQuality: {
343 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
344 unpricedShare: records === 0 ? 0 : unpricedRecords / records,
345 modelPricedShare:
346 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
347 cacheSavingsUsd,
348 },
349 duplicateSources: duplicates,
350 contributingEnvironments,
351 staleEnvironments,
352 };
353}
packages/shared/package.json+8
......@@ -210,6 +210,14 @@
210210 "./devProxy": {
211211 "types": "./src/devProxy.ts",
212212 "import": "./src/devProxy.ts"
213 },
214 "./usageMerge": {
215 "types": "./src/usageMerge.ts",
216 "import": "./src/usageMerge.ts"
217 },
218 "./usageFormat": {
219 "types": "./src/usageFormat.ts",
220 "import": "./src/usageFormat.ts"
213221 }
214222 },
215223 "scripts": {
packages/shared/src/usageFormat.ts created+108
......@@ -0,0 +1,108 @@
1// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl.
2/**
3 * Display formatting for the usage page.
4 *
5 * @module usageFormat
6 */
7import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
8
9const CURRENCY = new Intl.NumberFormat("en-US", {
10 style: "currency",
11 currency: "USD",
12 minimumFractionDigits: 2,
13 maximumFractionDigits: 2,
14});
15
16const INTEGER = new Intl.NumberFormat("en-US");
17
18export function formatUsd(value: number): string {
19 return CURRENCY.format(value);
20}
21
22export function formatCount(value: number): string {
23 return INTEGER.format(Math.round(value));
24}
25
26/**
27 * Compacts a token count to three significant figures with a unit suffix, so
28 * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`).
29 */
30export function formatTokens(value: number): string {
31 const abs = Math.abs(value);
32 if (abs >= 1e12) return `${trim(value / 1e12)}T`;
33 if (abs >= 1e9) return `${trim(value / 1e9)}B`;
34 if (abs >= 1e6) return `${trim(value / 1e6)}M`;
35 if (abs >= 1e3) return `${trim(value / 1e3)}K`;
36 return INTEGER.format(Math.round(value));
37}
38
39function trim(value: number): string {
40 const abs = Math.abs(value);
41 const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
42 return value.toFixed(digits).replace(/\.0+$/, "");
43}
44
45export function formatPercent(share: number, digits = 1): string {
46 return `${(share * 100).toFixed(digits)}%`;
47}
48
49/** `2026-08-07` to `Aug 7`. */
50export function formatDayShort(day: string): string {
51 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
52 if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
53 const MONTHS = [
54 "Jan",
55 "Feb",
56 "Mar",
57 "Apr",
58 "May",
59 "Jun",
60 "Jul",
61 "Aug",
62 "Sep",
63 "Oct",
64 "Nov",
65 "Dec",
66 ];
67 return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
68}
69
70/** Inclusive day list between two `YYYY-MM-DD` bounds. */
71export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
72 const days: string[] = [];
73 const start = Date.parse(`${sinceDay}T00:00:00Z`);
74 const end = Date.parse(`${untilDay}T00:00:00Z`);
75 if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
76
77 for (let cursor = start; cursor <= end; cursor += 86_400_000) {
78 days.push(new Date(cursor).toISOString().slice(0, 10));
79 }
80 return days;
81}
82
83/**
84 * The window the page requests, expressed in the viewer's own time zone so days
85 * line up with what they actually experienced.
86 */
87export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
88 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
89 const format = new Intl.DateTimeFormat("en-CA", {
90 timeZone,
91 year: "numeric",
92 month: "2-digit",
93 day: "2-digit",
94 });
95 const untilDay = format.format(now);
96 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
97 // around a DST transition. Only "today" needs the zone; the window start is
98 // pure calendar arithmetic on that day, done in UTC where days are uniform.
99 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
100 .split("-")
101 .map((part) => Number.parseInt(part, 10));
102 const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1)));
103 return {
104 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
105 untilDay: UsageDay.make(untilDay),
106 timeZone,
107 };
108}
packages/shared/src/usageMerge.test.ts created+258
......@@ -0,0 +1,258 @@
1import {
2 USAGE_CONTRACT_VERSION,
3 type EnvironmentId,
4 type UsageBucket,
5 type UsageDay,
6 type UsageProviderKind,
7 type UsageSummary,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10
11import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
12
13function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
14 return {
15 day: "2026-08-07" as UsageDay,
16 provider: "claude",
17 model: "claude-fable-5",
18 totals: {
19 uncachedInputTokens: 100,
20 cachedInputTokens: 1000,
21 cacheCreationTokens: 10,
22 outputTokens: 50,
23 reasoningTokens: 0,
24 },
25 costUsd: 10,
26 cacheSavingsUsd: 2,
27 costSource: "modelPriced",
28 records: 5,
29 unpricedRecords: 0,
30 sessions: 1,
31 ...overrides,
32 };
33}
34
35function summary(
36 buckets: readonly UsageBucket[],
37 sources: readonly {
38 provider: UsageProviderKind;
39 hostId: string;
40 homePath: string;
41 volumeId?: string;
42 distinctSessions?: number;
43 }[],
44 contractVersion: number = USAGE_CONTRACT_VERSION,
45): UsageSummary {
46 return {
47 contractVersion,
48 readAt: "2026-08-07T00:00:00.000Z",
49 timeZone: "UTC",
50 sinceDay: "2026-08-01" as UsageDay,
51 untilDay: "2026-08-31" as UsageDay,
52 buckets,
53 sources: sources.map((source) => ({
54 fingerprint: {
55 hostId: source.hostId,
56 provider: source.provider,
57 resolvedHomePath: source.homePath,
58 volumeId: source.volumeId ?? `vol-${source.hostId}`,
59 },
60 status: "ok" as const,
61 scannedFiles: 1,
62 skippedFiles: 0,
63 malformedRecords: 0,
64 distinctSessions: source.distinctSessions ?? 1,
65 message: null,
66 })),
67 pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
68 scanDurationMs: 1,
69 };
70}
71
72function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
73 return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
74}
75
76describe("mergeUsage", () => {
77 it("sums environments that read different transcript directories", () => {
78 const merged = mergeUsage(
79 [
80 environment(
81 "env-a",
82 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]),
83 ),
84 environment(
85 "env-b",
86 summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]),
87 ),
88 ],
89 USAGE_CONTRACT_VERSION,
90 );
91
92 expect(merged.costUsd).toBe(20);
93 expect(merged.records).toBe(10);
94 expect(merged.duplicateSources).toHaveLength(0);
95 });
96
97 it("counts a shared transcript directory once", () => {
98 // Two worktree servers on one machine resolve the same provider home.
99 const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
100 const merged = mergeUsage(
101 [
102 environment("env-a", summary([bucket()], [shared])),
103 environment("env-b", summary([bucket()], [shared])),
104 ],
105 USAGE_CONTRACT_VERSION,
106 );
107
108 expect(merged.costUsd).toBe(10);
109 expect(merged.records).toBe(5);
110 expect(merged.sessions).toBe(1);
111 expect(merged.duplicateSources).toHaveLength(1);
112 expect(merged.contributingEnvironments).toEqual(["env-a"]);
113 });
114
115 it("drops only the duplicated provider, keeping the environment's other one", () => {
116 const sharedClaude = {
117 provider: "claude" as const,
118 hostId: "mac",
119 homePath: "/home/theo/.claude",
120 };
121 const merged = mergeUsage(
122 [
123 environment("env-a", summary([bucket()], [sharedClaude])),
124 environment(
125 "env-b",
126 summary(
127 [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })],
128 [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }],
129 ),
130 ),
131 ],
132 USAGE_CONTRACT_VERSION,
133 );
134
135 // env-b's claude bucket is dropped, its codex bucket survives.
136 expect(merged.costUsd).toBe(14);
137 expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([
138 "claude",
139 "codex",
140 ]);
141 });
142
143 it("excludes an environment reporting an older contract version", () => {
144 const merged = mergeUsage(
145 [
146 environment(
147 "env-a",
148 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]),
149 ),
150 environment(
151 "env-b",
152 summary(
153 [bucket()],
154 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
155 USAGE_CONTRACT_VERSION - 1,
156 ),
157 ),
158 ],
159 USAGE_CONTRACT_VERSION,
160 );
161
162 expect(merged.costUsd).toBe(10);
163 expect(merged.staleEnvironments).toEqual(["env-b"]);
164 });
165
166 it("derives provider shares and cost quality", () => {
167 const merged = mergeUsage(
168 [
169 environment(
170 "env-a",
171 summary(
172 [
173 bucket({ costUsd: 75 }),
174 bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }),
175 ],
176 [
177 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
178 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
179 ],
180 ),
181 ),
182 ],
183 USAGE_CONTRACT_VERSION,
184 );
185
186 expect(merged.providers[0]?.provider).toBe("claude");
187 expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5);
188 expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5);
189 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
190 });
191
192 it("keeps two machines apart when hostname and home path collide", () => {
193 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
194 // one machine's usage vanish. Filesystem identity separates them.
195 const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" };
196 const merged = mergeUsage(
197 [
198 environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])),
199 environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])),
200 ],
201 USAGE_CONTRACT_VERSION,
202 );
203
204 expect(merged.costUsd).toBe(20);
205 expect(merged.duplicateSources).toHaveLength(0);
206 });
207
208 it("still collapses two servers reading the same directory", () => {
209 const same = {
210 provider: "claude" as const,
211 hostId: "mac",
212 homePath: "/Users/theo/.claude",
213 volumeId: "16777220:1234",
214 };
215 const merged = mergeUsage(
216 [
217 environment("env-a", summary([bucket()], [same])),
218 environment("env-b", summary([bucket()], [same])),
219 ],
220 USAGE_CONTRACT_VERSION,
221 );
222
223 expect(merged.costUsd).toBe(10);
224 expect(merged.duplicateSources).toHaveLength(1);
225 });
226
227 it("totals sessions from per-directory distinct counts, not per-bucket sums", () => {
228 // One session that spans two days appears in two buckets. Summing bucket
229 // sessions would say 2; the source's distinct count says 1.
230 const merged = mergeUsage(
231 [
232 environment(
233 "env-a",
234 summary(
235 [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })],
236 [
237 {
238 provider: "claude",
239 hostId: "mac",
240 homePath: "/a/.claude",
241 distinctSessions: 1,
242 },
243 ],
244 ),
245 ),
246 ],
247 USAGE_CONTRACT_VERSION,
248 );
249
250 expect(merged.sessions).toBe(1);
251 });
252
253 it("returns empty totals with no environments", () => {
254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
255 expect(merged.costUsd).toBe(0);
256 expect(merged.daily).toHaveLength(0);
257 });
258});
packages/shared/src/usageMerge.ts created+353
......@@ -0,0 +1,353 @@
1/**
2 * Merges per-environment usage summaries into the single view the page renders.
3 *
4 * Pure, so the de-duplication and derivation rules can be tested without a
5 * connected environment.
6 *
7 * @module usageMerge
8 */
9import type {
10 EnvironmentId,
11 UsageBucket,
12 UsageProviderKind,
13 UsageSourceFingerprint,
14 UsageSummary,
15} from "@t3tools/contracts";
16
17export interface EnvironmentUsage {
18 readonly environmentId: EnvironmentId;
19 readonly label: string;
20 readonly summary: UsageSummary;
21}
22
23export interface ProviderTotals {
24 readonly provider: UsageProviderKind;
25 readonly costUsd: number;
26 readonly totalTokens: number;
27 readonly records: number;
28 readonly costShare: number;
29 readonly tokenShare: number;
30}
31
32export interface ModelTotals {
33 readonly model: string;
34 readonly provider: UsageProviderKind;
35 readonly costUsd: number;
36 readonly totalTokens: number;
37 readonly records: number;
38 readonly costShare: number;
39}
40
41export interface DailyTotals {
42 readonly day: string;
43 readonly costUsd: number;
44 readonly totalTokens: number;
45 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
46}
47
48export interface CostQuality {
49 readonly providerReportedShare: number;
50 readonly modelPricedShare: number;
51 readonly unpricedShare: number;
52 readonly cacheSavingsUsd: number;
53}
54
55export interface MergedUsage {
56 readonly costUsd: number;
57 readonly uncachedInputTokens: number;
58 readonly cachedInputTokens: number;
59 readonly cacheCreationTokens: number;
60 readonly outputTokens: number;
61 readonly reasoningTokens: number;
62 readonly totalTokens: number;
63 readonly records: number;
64 readonly sessions: number;
65 readonly providers: readonly ProviderTotals[];
66 readonly models: readonly ModelTotals[];
67 readonly daily: readonly DailyTotals[];
68 readonly costQuality: CostQuality;
69 /** Environments whose data was dropped as a duplicate of another's. */
70 readonly duplicateSources: readonly string[];
71 readonly contributingEnvironments: readonly EnvironmentId[];
72 readonly staleEnvironments: readonly EnvironmentId[];
73}
74
75/**
76 * Two sources are the same physical transcript directory only when host,
77 * provider, path and filesystem identity all agree.
78 *
79 * `volumeId` is what stops two machines that happen to share a hostname and a
80 * home path, which is every Mac in a fleet, from collapsing into one source and
81 * having one of them silently dropped.
82 */
83function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
84 return [
85 fingerprint.hostId,
86 fingerprint.provider,
87 fingerprint.resolvedHomePath,
88 fingerprint.volumeId,
89 ].join(" ");
90}
91
92/**
93 * Decides which environment owns each physical transcript directory.
94 *
95 * Several environments on one machine (worktree servers, for instance) resolve
96 * the same provider home and would otherwise double count every token. The
97 * first environment in a stable order claims a fingerprint; the rest have that
98 * provider's buckets dropped. Environments are sorted by id so the winner does
99 * not change between renders.
100 */
101function claimSources(environments: readonly EnvironmentUsage[]): {
102 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
103 readonly duplicates: readonly string[];
104} {
105 const ownerByFingerprint = new Map<string, EnvironmentId>();
106 const duplicates: string[] = [];
107
108 const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
109
110 for (const environment of ordered) {
111 for (const source of environment.summary.sources) {
112 if (source.status === "missing") continue;
113 const key = fingerprintKey(source.fingerprint);
114 if (ownerByFingerprint.has(key)) {
115 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
116 continue;
117 }
118 ownerByFingerprint.set(key, environment.environmentId);
119 }
120 }
121
122 return { ownerByFingerprint, duplicates };
123}
124
125/** Sources this environment owns after fingerprint claims, plus their buckets. */
126function ownedContribution(
127 environment: EnvironmentUsage,
128 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
129): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
130 const ownedProviders = new Set<UsageProviderKind>();
131 let sessions = 0;
132 for (const source of environment.summary.sources) {
133 if (source.status === "missing") continue;
134 const key = fingerprintKey(source.fingerprint);
135 if (ownerByFingerprint.get(key) === environment.environmentId) {
136 ownedProviders.add(source.fingerprint.provider);
137 // Distinct within a directory. Summing per-bucket session counts instead
138 // would count a session once per day and model it spans.
139 sessions += source.distinctSessions;
140 }
141 }
142 return {
143 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
144 sessions,
145 };
146}
147
148function bucketTokens(bucket: UsageBucket): number {
149 // reasoningTokens is a subset of outputTokens and must not be added again.
150 return (
151 bucket.totals.uncachedInputTokens +
152 bucket.totals.cachedInputTokens +
153 bucket.totals.cacheCreationTokens +
154 bucket.totals.outputTokens
155 );
156}
157
158const EMPTY_MERGED: MergedUsage = {
159 costUsd: 0,
160 uncachedInputTokens: 0,
161 cachedInputTokens: 0,
162 cacheCreationTokens: 0,
163 outputTokens: 0,
164 reasoningTokens: 0,
165 totalTokens: 0,
166 records: 0,
167 sessions: 0,
168 providers: [],
169 models: [],
170 daily: [],
171 costQuality: {
172 providerReportedShare: 0,
173 modelPricedShare: 0,
174 unpricedShare: 0,
175 cacheSavingsUsd: 0,
176 },
177 duplicateSources: [],
178 contributingEnvironments: [],
179 staleEnvironments: [],
180};
181
182/**
183 * Merges every connected environment's summary.
184 *
185 * `expectedContractVersion` guards against an environment running older server
186 * code: rather than blocking the page, its data is excluded and its id is
187 * reported so the UI can say coverage is partial.
188 */
189export function mergeUsage(
190 environments: readonly EnvironmentUsage[],
191 expectedContractVersion: number,
192): MergedUsage {
193 if (environments.length === 0) return EMPTY_MERGED;
194
195 const current: EnvironmentUsage[] = [];
196 const staleEnvironments: EnvironmentId[] = [];
197 for (const environment of environments) {
198 if (environment.summary.contractVersion === expectedContractVersion) {
199 current.push(environment);
200 } else {
201 staleEnvironments.push(environment.environmentId);
202 }
203 }
204
205 const { ownerByFingerprint, duplicates } = claimSources(current);
206
207 let costUsd = 0;
208 let uncachedInputTokens = 0;
209 let cachedInputTokens = 0;
210 let cacheCreationTokens = 0;
211 let outputTokens = 0;
212 let reasoningTokens = 0;
213 let records = 0;
214 let sessions = 0;
215 let cacheSavingsUsd = 0;
216 let providerReportedRecords = 0;
217 let unpricedRecords = 0;
218
219 const providerAccumulator = new Map<
220 UsageProviderKind,
221 { costUsd: number; totalTokens: number; records: number }
222 >();
223 const modelAccumulator = new Map<
224 string,
225 { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }
226 >();
227 const dailyAccumulator = new Map<
228 string,
229 {
230 costUsd: number;
231 totalTokens: number;
232 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
233 }
234 >();
235 const contributingEnvironments: EnvironmentId[] = [];
236
237 for (const environment of current) {
238 const { buckets, sessions: environmentSessions } = ownedContribution(
239 environment,
240 ownerByFingerprint,
241 );
242 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
243 sessions += environmentSessions;
244
245 for (const bucket of buckets) {
246 const tokens = bucketTokens(bucket);
247
248 costUsd += bucket.costUsd;
249 cacheSavingsUsd += bucket.cacheSavingsUsd;
250 uncachedInputTokens += bucket.totals.uncachedInputTokens;
251 cachedInputTokens += bucket.totals.cachedInputTokens;
252 cacheCreationTokens += bucket.totals.cacheCreationTokens;
253 outputTokens += bucket.totals.outputTokens;
254 reasoningTokens += bucket.totals.reasoningTokens;
255 records += bucket.records;
256 unpricedRecords += bucket.unpricedRecords;
257 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
258
259 const provider = providerAccumulator.get(bucket.provider) ?? {
260 costUsd: 0,
261 totalTokens: 0,
262 records: 0,
263 };
264 provider.costUsd += bucket.costUsd;
265 provider.totalTokens += tokens;
266 provider.records += bucket.records;
267 providerAccumulator.set(bucket.provider, provider);
268
269 const modelKey = `${bucket.provider} ${bucket.model}`;
270 const model = modelAccumulator.get(modelKey) ?? {
271 provider: bucket.provider,
272 costUsd: 0,
273 totalTokens: 0,
274 records: 0,
275 };
276 model.costUsd += bucket.costUsd;
277 model.totalTokens += tokens;
278 model.records += bucket.records;
279 modelAccumulator.set(modelKey, model);
280
281 const day = dailyAccumulator.get(bucket.day) ?? {
282 costUsd: 0,
283 totalTokens: 0,
284 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
285 };
286 day.costUsd += bucket.costUsd;
287 day.totalTokens += tokens;
288 const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
289 dayProvider.costUsd += bucket.costUsd;
290 dayProvider.totalTokens += tokens;
291 day.byProvider.set(bucket.provider, dayProvider);
292 dailyAccumulator.set(bucket.day, day);
293 }
294 }
295
296 const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
297
298 const providers: ProviderTotals[] = [...providerAccumulator.entries()]
299 .map(([provider, totals]) => ({
300 provider,
301 costUsd: totals.costUsd,
302 totalTokens: totals.totalTokens,
303 records: totals.records,
304 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
305 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
306 }))
307 .sort((a, b) => b.costUsd - a.costUsd);
308
309 const models: ModelTotals[] = [...modelAccumulator.entries()]
310 .map(([key, totals]) => ({
311 model: key.slice(key.indexOf(" ") + 1),
312 provider: totals.provider,
313 costUsd: totals.costUsd,
314 totalTokens: totals.totalTokens,
315 records: totals.records,
316 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
317 }))
318 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
319
320 const daily: DailyTotals[] = [...dailyAccumulator.entries()]
321 .map(([day, totals]) => ({
322 day,
323 costUsd: totals.costUsd,
324 totalTokens: totals.totalTokens,
325 byProvider: totals.byProvider,
326 }))
327 .sort((a, b) => a.day.localeCompare(b.day));
328
329 return {
330 costUsd,
331 uncachedInputTokens,
332 cachedInputTokens,
333 cacheCreationTokens,
334 outputTokens,
335 reasoningTokens,
336 totalTokens,
337 records,
338 sessions,
339 providers,
340 models,
341 daily,
342 costQuality: {
343 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
344 unpricedShare: records === 0 ? 0 : unpricedRecords / records,
345 modelPricedShare:
346 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
347 cacheSavingsUsd,
348 },
349 duplicateSources: duplicates,
350 contributingEnvironments,
351 staleEnvironments,
352 };
353}