| author | |
| committer | |
| log | 1a003e383ac6b10258b8100c2617d938c4f06c69 |
| tree | 4d8a6d434210f59f509fa86124130436eb8ac2d3 |
| parent | 963ebf5bd7cce00d40ff60c258b34c12dcab271e |
| signature | Signed by PGP key B5690EEEBB952194 |
20 files changed, 1475 insertions(+), 723 deletions(-)
apps/mobile/src/Stack.tsx+8| ... | @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc | ... | @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc |
| 48 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 48 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 49 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 49 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 50 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | 50 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 51 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; | ||
| 51 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 52 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 52 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | 53 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 53 | import { | 54 | import { |
| ... | @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({ | ... | @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 190 | title: "Client Storage", | 191 | title: "Client Storage", |
| 191 | }, | 192 | }, |
| 192 | }), | 193 | }), |
| 194 | SettingsUsage: createNativeStackScreen({ | ||
| 195 | screen: UsageRouteScreen, | ||
| 196 | linking: "usage", | ||
| 197 | options: { | ||
| 198 | title: "Usage", | ||
| 199 | }, | ||
| 200 | }), | ||
| 193 | SettingsAuth: createNativeStackScreen({ | 201 | SettingsAuth: createNativeStackScreen({ |
| 194 | screen: SettingsAuthRouteScreen, | 202 | screen: SettingsAuthRouteScreen, |
| 195 | linking: "auth", | 203 | linking: "auth", |
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | IconBellRinging, | 14 | IconBellRinging, |
| 15 | IconBolt, | 15 | IconBolt, |
| 16 | IconCamera, | 16 | IconCamera, |
| 17 | IconChartBar, | ||
| 17 | IconCheck, | 18 | IconCheck, |
| 18 | IconChevronDown, | 19 | IconChevronDown, |
| 19 | IconCode, | 20 | IconCode, |
| ... | @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 97 | "bolt.circle": IconBolt, | 98 | "bolt.circle": IconBolt, |
| 98 | "bolt.horizontal.circle": IconBolt, | 99 | "bolt.horizontal.circle": IconBolt, |
| 99 | camera: IconCamera, | 100 | camera: IconCamera, |
| 101 | "chart.bar.xaxis": IconChartBar, | ||
| 100 | checkmark: IconCheck, | 102 | checkmark: IconCheck, |
| 101 | "checkmark.circle": IconCircleCheck, | 103 | "checkmark.circle": IconCircleCheck, |
| 102 | clock: IconClock, | 104 | clock: IconClock, |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1| ... | @@ -533,6 +533,7 @@ function GeneralSettingsSection() { | ... | @@ -533,6 +533,7 @@ function GeneralSettingsSection() { |
| 533 | return ( | 533 | return ( |
| 534 | <SettingsSection title="General"> | 534 | <SettingsSection title="General"> |
| 535 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> | 535 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 536 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | ||
| 536 | </SettingsSection> | 537 | </SettingsSection> |
| 537 | ); | 538 | ); |
| 538 | } | 539 | } |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+2-1| ... | @@ -3,6 +3,7 @@ export type SettingsSheetTarget = | ... | @@ -3,6 +3,7 @@ export type SettingsSheetTarget = |
| 3 | | "SettingsArchive" | 3 | | "SettingsArchive" |
| 4 | | "SettingsAppearance" | 4 | | "SettingsAppearance" |
| 5 | | "SettingsProjectGrouping" | 5 | | "SettingsProjectGrouping" |
| 6 | | "SettingsClientStorage"; | 6 | | "SettingsClientStorage" |
| 7 | | "SettingsUsage"; | ||
| 7 | 8 | ||
| 8 | export type SettingsLegalDocumentTarget = "SettingsLegal"; | 9 | export type SettingsLegalDocumentTarget = "SettingsLegal"; |
apps/mobile/src/features/usage/UsageDailyChart.ios.tsx created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui"; | ||
| 2 | import { frame } from "@expo/ui/swift-ui/modifiers"; | ||
| 3 | import { useMemo } from "react"; | ||
| 4 | |||
| 5 | import type { DailyTotals } from "@t3tools/shared/usageMerge"; | ||
| 6 | |||
| 7 | import { buildChartDays, type UsageChartMetric } from "./usageChartData"; | ||
| 8 | import { useProviderColors } from "./usageProviders"; | ||
| 9 | |||
| 10 | export 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 | */ | ||
| 25 | export 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 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | |||
| 4 | import type { DailyTotals } from "@t3tools/shared/usageMerge"; | ||
| 5 | |||
| 6 | import { buildChartDays, type UsageChartMetric } from "./usageChartData"; | ||
| 7 | import { useProviderColors } from "./usageProviders"; | ||
| 8 | |||
| 9 | export 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 | */ | ||
| 20 | export 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 @@ | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import type { MergedUsage } from "@t3tools/shared/usageMerge"; | ||
| 3 | import { | ||
| 4 | enumerateDays, | ||
| 5 | formatCount, | ||
| 6 | formatDayShort, | ||
| 7 | formatPercent, | ||
| 8 | formatTokens, | ||
| 9 | formatUsd, | ||
| 10 | makeWindow, | ||
| 11 | } from "@t3tools/shared/usageFormat"; | ||
| 12 | import { useMemo, useState } from "react"; | ||
| 13 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | ||
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 15 | |||
| 16 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 17 | import { AppText as Text } from "../../components/AppText"; | ||
| 18 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 19 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | ||
| 20 | import { SettingsSection } from "../settings/components/SettingsSection"; | ||
| 21 | import { UsageDailyChart } from "./UsageDailyChart"; | ||
| 22 | import type { UsageChartMetric } from "./usageChartData"; | ||
| 23 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | ||
| 24 | |||
| 25 | const WINDOW_OPTIONS = [ | ||
| 26 | { days: 7, label: "7 days" }, | ||
| 27 | { days: 30, label: "30 days" }, | ||
| 28 | { days: 90, label: "90 days" }, | ||
| 29 | ] as const; | ||
| 30 | |||
| 31 | const CHART_HEIGHT = 180; | ||
| 32 | |||
| 33 | export 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 | |||
| 106 | function 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. */ | ||
| 142 | function 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 | |||
| 207 | function 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 | |||
| 239 | function 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 | |||
| 295 | function 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 | |||
| 344 | function 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 | |||
| 358 | function 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 | */ | ||
| 398 | function 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 | */ | ||
| 7 | import type { UsageProviderKind } from "@t3tools/contracts"; | ||
| 8 | import type { DailyTotals } from "@t3tools/shared/usageMerge"; | ||
| 9 | |||
| 10 | import { PROVIDER_ORDER } from "./usageProviders"; | ||
| 11 | |||
| 12 | export type UsageChartMetric = "cost" | "tokens"; | ||
| 13 | |||
| 14 | export 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. */ | ||
| 22 | export 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 @@ | ||
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | ||
| 2 | import { 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 | */ | ||
| 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; | ||
| 9 | |||
| 10 | export 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 | */ | ||
| 19 | export 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 | */ | ||
| 12 | import { useAtomValue } from "@effect/atom-react"; | ||
| 13 | import { | ||
| 14 | USAGE_CONTRACT_VERSION, | ||
| 15 | type EnvironmentId, | ||
| 16 | type UsageSummary, | ||
| 17 | type UsageSummaryInput, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 22 | import { useCallback, useMemo } from "react"; | ||
| 23 | |||
| 24 | import { appAtomRegistry } from "./atom-registry"; | ||
| 25 | import { environmentPresentations } from "./presentation"; | ||
| 26 | import { serverEnvironment } from "./server"; | ||
| 27 | |||
| 28 | export 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 | */ | ||
| 43 | const 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 | |||
| 63 | export 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 | |||
| 77 | export 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 { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | formatTokens, | 13 | formatTokens, |
| 14 | formatUsd, | 14 | formatUsd, |
| 15 | makeWindow, | 15 | makeWindow, |
| 16 | } from "../../usage/usageFormat"; | 16 | } from "@t3tools/shared/usageFormat"; |
| 17 | import { ScrollArea } from "../ui/scroll-area"; | 17 | import { ScrollArea } from "../ui/scroll-area"; |
| 18 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 18 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 19 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 19 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
apps/web/src/components/usage/UsageProviderChart.tsx+2-2| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | import { useCallback, useMemo, useRef, useState } from "react"; | 2 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 3 | 3 | ||
| 4 | import type { DailyTotals } from "../../usage/usageMerge"; | 4 | import type { DailyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; | 5 | import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; |
| 6 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 6 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 7 | 7 | ||
| 8 | const VIEW_WIDTH = 960; | 8 | const VIEW_WIDTH = 960; |
apps/web/src/state/usage.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as Option from "effect/Option"; | ... | @@ -17,7 +17,7 @@ import * as Option from "effect/Option"; |
| 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 18 | import { useCallback, useMemo } from "react"; | 18 | import { useCallback, useMemo } from "react"; |
| 19 | 19 | ||
| 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 22 | import { environmentPresentations } from "./presentation"; | 22 | import { environmentPresentations } from "./presentation"; |
| 23 | import { serverEnvironment } from "./server"; | 23 | import { 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 | */ | ||
| 6 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | const CURRENCY = new Intl.NumberFormat("en-US", { | ||
| 9 | style: "currency", | ||
| 10 | currency: "USD", | ||
| 11 | minimumFractionDigits: 2, | ||
| 12 | maximumFractionDigits: 2, | ||
| 13 | }); | ||
| 14 | |||
| 15 | const INTEGER = new Intl.NumberFormat("en-US"); | ||
| 16 | |||
| 17 | export function formatUsd(value: number): string { | ||
| 18 | return CURRENCY.format(value); | ||
| 19 | } | ||
| 20 | |||
| 21 | export 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 | */ | ||
| 29 | export 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 | |||
| 38 | function 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 | |||
| 44 | export function formatPercent(share: number, digits = 1): string { | ||
| 45 | return `${(share * 100).toFixed(digits)}%`; | ||
| 46 | } | ||
| 47 | |||
| 48 | /** `2026-08-07` to `Aug 7`. */ | ||
| 49 | export 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. */ | ||
| 70 | export 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 | */ | ||
| 86 | export 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 @@ | ||
| 1 | import { | ||
| 2 | USAGE_CONTRACT_VERSION, | ||
| 3 | type EnvironmentId, | ||
| 4 | type UsageBucket, | ||
| 5 | type UsageDay, | ||
| 6 | type UsageProviderKind, | ||
| 7 | type UsageSummary, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { mergeUsage, type EnvironmentUsage } from "./usageMerge"; | ||
| 12 | |||
| 13 | function 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 | |||
| 35 | function 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 | |||
| 72 | function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { | ||
| 73 | return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; | ||
| 74 | } | ||
| 75 | |||
| 76 | describe("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 | */ | ||
| 9 | import type { | ||
| 10 | EnvironmentId, | ||
| 11 | UsageBucket, | ||
| 12 | UsageProviderKind, | ||
| 13 | UsageSourceFingerprint, | ||
| 14 | UsageSummary, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | |||
| 17 | export interface EnvironmentUsage { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly label: string; | ||
| 20 | readonly summary: UsageSummary; | ||
| 21 | } | ||
| 22 | |||
| 23 | export 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 | |||
| 32 | export 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 | |||
| 41 | export 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 | |||
| 48 | export interface CostQuality { | ||
| 49 | readonly providerReportedShare: number; | ||
| 50 | readonly modelPricedShare: number; | ||
| 51 | readonly unpricedShare: number; | ||
| 52 | readonly cacheSavingsUsd: number; | ||
| 53 | } | ||
| 54 | |||
| 55 | export 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 | */ | ||
| 83 | function 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 | */ | ||
| 101 | function 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. */ | ||
| 126 | function 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 | |||
| 148 | function 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 | |||
| 158 | const 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 | */ | ||
| 189 | export 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 @@ | ... | @@ -210,6 +210,14 @@ |
| 210 | "./devProxy": { | 210 | "./devProxy": { |
| 211 | "types": "./src/devProxy.ts", | 211 | "types": "./src/devProxy.ts", |
| 212 | "import": "./src/devProxy.ts" | 212 | "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" | ||
| 213 | } | 221 | } |
| 214 | }, | 222 | }, |
| 215 | "scripts": { | 223 | "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 | */ | ||
| 7 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | const CURRENCY = new Intl.NumberFormat("en-US", { | ||
| 10 | style: "currency", | ||
| 11 | currency: "USD", | ||
| 12 | minimumFractionDigits: 2, | ||
| 13 | maximumFractionDigits: 2, | ||
| 14 | }); | ||
| 15 | |||
| 16 | const INTEGER = new Intl.NumberFormat("en-US"); | ||
| 17 | |||
| 18 | export function formatUsd(value: number): string { | ||
| 19 | return CURRENCY.format(value); | ||
| 20 | } | ||
| 21 | |||
| 22 | export 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 | */ | ||
| 30 | export 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 | |||
| 39 | function 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 | |||
| 45 | export function formatPercent(share: number, digits = 1): string { | ||
| 46 | return `${(share * 100).toFixed(digits)}%`; | ||
| 47 | } | ||
| 48 | |||
| 49 | /** `2026-08-07` to `Aug 7`. */ | ||
| 50 | export 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. */ | ||
| 71 | export 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 | */ | ||
| 87 | export 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 @@ | ||
| 1 | import { | ||
| 2 | USAGE_CONTRACT_VERSION, | ||
| 3 | type EnvironmentId, | ||
| 4 | type UsageBucket, | ||
| 5 | type UsageDay, | ||
| 6 | type UsageProviderKind, | ||
| 7 | type UsageSummary, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; | ||
| 12 | |||
| 13 | function 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 | |||
| 35 | function 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 | |||
| 72 | function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { | ||
| 73 | return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; | ||
| 74 | } | ||
| 75 | |||
| 76 | describe("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 | */ | ||
| 9 | import type { | ||
| 10 | EnvironmentId, | ||
| 11 | UsageBucket, | ||
| 12 | UsageProviderKind, | ||
| 13 | UsageSourceFingerprint, | ||
| 14 | UsageSummary, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | |||
| 17 | export interface EnvironmentUsage { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly label: string; | ||
| 20 | readonly summary: UsageSummary; | ||
| 21 | } | ||
| 22 | |||
| 23 | export 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 | |||
| 32 | export 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 | |||
| 41 | export 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 | |||
| 48 | export interface CostQuality { | ||
| 49 | readonly providerReportedShare: number; | ||
| 50 | readonly modelPricedShare: number; | ||
| 51 | readonly unpricedShare: number; | ||
| 52 | readonly cacheSavingsUsd: number; | ||
| 53 | } | ||
| 54 | |||
| 55 | export 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 | */ | ||
| 83 | function 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 | */ | ||
| 101 | function 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. */ | ||
| 126 | function 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 | |||
| 148 | function 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 | |||
| 158 | const 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 | */ | ||
| 189 | export 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 | } | ||