diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc731b835e066f42f1cb1915ead7a9a54..da1be88a8bdbbfdd82b5f801b01c8c5dd77117fc 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsage: createNativeStackScreen({ + screen: UsageRouteScreen, + linking: "usage", + options: { + title: "Usage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index b1e83607d8e7db75dd08d7824f863f42cdfed4b3..89dc0cc045b2d55f45ac77c1b9cf44fc4d75c259 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -14,6 +14,7 @@ import { IconBellRinging, IconBolt, IconCamera, + IconChartBar, IconCheck, IconChevronDown, IconCode, @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, camera: IconCamera, + "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 8bfff6a87478f96139612b5a72033bd08070f280..90e5af199dec40424eddd349b37535014798ba52 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -533,6 +533,7 @@ function GeneralSettingsSection() { return ( + ); } diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index df012c90325679bf3c031937d88de4572ad9d55c..7189fdc2ebe5d020c927b3b38c0a2a944b61b4f6 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -3,6 +3,7 @@ export type SettingsSheetTarget = | "SettingsArchive" | "SettingsAppearance" | "SettingsProjectGrouping" - | "SettingsClientStorage"; + | "SettingsClientStorage" + | "SettingsUsage"; export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5924bd8366f6cd84f4fdedd27a4e851860f9c7c --- /dev/null +++ b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx @@ -0,0 +1,50 @@ +import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui"; +import { frame } from "@expo/ui/swift-ui/modifiers"; +import { useMemo } from "react"; + +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { buildChartDays, type UsageChartMetric } from "./usageChartData"; +import { useProviderColors } from "./usageProviders"; + +export interface UsageDailyChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; + readonly height: number; +} + +/** + * Native Swift Charts daily bars. Points sharing an x value stack, so emitting + * one point per provider per day yields per-provider bands whose stack height + * is the day's total; changes animate natively. + * + * Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the + * screen renders its own edge labels under the chart instead. + */ +export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { + const colors = useProviderColors(); + + const data = useMemo((): ChartDataPoint[] => { + return buildChartDays(days, daily, metric).flatMap((day) => + day.values.map((entry) => ({ + x: day.day, + y: entry.value, + color: colors[entry.provider], + })), + ); + }, [days, daily, metric, colors]); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageDailyChart.tsx b/apps/mobile/src/features/usage/UsageDailyChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55e2de3157220c695d0fd8691a82db11e9ed8073 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageDailyChart.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { View } from "react-native"; + +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { buildChartDays, type UsageChartMetric } from "./usageChartData"; +import { useProviderColors } from "./usageProviders"; + +export interface UsageDailyChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; + readonly height: number; +} + +/** + * Stacked daily bars drawn with plain views. Android and any platform without + * Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead. + */ +export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { + const colors = useProviderColors(); + const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]); + const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0); + + return ( + + {/* column-reverse stacks the bottom-first provider values upward + without reversing the array (Hermes lacks Array#toReversed). */} + {chartDays.map((day) => ( + + {day.values.map((entry) => ( + + ))} + + ))} + + ); +} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..54a9ac7bc21d0715b1c7d70914430a92e6a6cebe --- /dev/null +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -0,0 +1,442 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MergedUsage } from "@t3tools/shared/usageMerge"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "@t3tools/shared/usageFormat"; +import { useMemo, useState } from "react"; +import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { UsageDailyChart } from "./UsageDailyChart"; +import type { UsageChartMetric } from "./usageChartData"; +import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +const CHART_HEIGHT = 180; + +export function UsageRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("cost"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + + // The pull spinner tracks re-scans of environments that have answered + // before. The initial scan renders its own placeholder, and an unreachable + // environment stays pending forever — neither may pin the spinner on. + const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + } + > + ({ value: option.days, label: option.label }))} + selected={windowDays} + onSelect={setWindowDays} + /> + + + + {isPending ? ( + + Scanning provider transcripts… + + ) : environments.length === 0 ? ( + + Connect an environment to see usage. + + ) : ( + <> + + + + + + )} + + + ); +} + +function SegmentedControl(props: { + readonly options: readonly { readonly value: Value; readonly label: string }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; +}) { + return ( + + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={ + active + ? "flex-1 items-center rounded-full bg-subtle-strong py-2" + : "flex-1 items-center py-2" + } + > + + {option.label} + + + ); + })} + + ); +} + +/** Headline figure, the animated daily chart, and its legend, in one card. */ +function ChartCard(props: { + readonly merged: MergedUsage; + readonly days: readonly string[]; + readonly metric: UsageChartMetric; + readonly onMetricChange: (metric: UsageChartMetric) => void; + readonly sinceDay: string; + readonly untilDay: string; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + const hasActivity = merged.daily.some((day) => day.totalTokens > 0); + + return ( + + + + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Across ${formatCount(merged.sessions)} sessions`} + + + + + + {hasActivity ? ( + + ) : ( + + No activity in this window. + + )} + + + {formatDayShort(props.sinceDay)} + + {merged.providers.map((provider) => ( + + + + {PROVIDER_LABEL[provider.provider]} + + + ))} + + {formatDayShort(props.untilDay)} + + + ); +} + +function MetricToggle(props: { + readonly metric: UsageChartMetric; + readonly onChange: (metric: UsageChartMetric) => void; +}) { + return ( + + {(["cost", "tokens"] as const).map((option) => { + const active = option === props.metric; + return ( + props.onChange(option)} + className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"} + > + + {option} + + + ); + })} + + ); +} + +function ProviderSection(props: { + readonly merged: MergedUsage; + readonly metric: UsageChartMetric; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + if (merged.providers.length === 0) return null; + + // Ranked by whatever the toggle is showing, so the rows always descend. + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. + const ordered = [...merged.providers].sort((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ); + + return ( + + {ordered.map((provider, index) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( + + + + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + + + + + + + + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + + + ); + })} + + ); +} + +function TotalsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` + : "vs full input rates" + } + /> + + + + + + + ); +} + +function MetricCell(props: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( + + {props.label} + {props.value} + {props.detail} + + ); +} + +function ModelsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const colors = useProviderColors(); + if (merged.models.length === 0) return null; + + return ( + + {merged.models.map((model, index) => ( + + + + + {model.model} + + + {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + + + {formatUsd(model.costUsd)} + + ))} + + ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice(props: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly merged: MergedUsage; + readonly isPartial: boolean; +}) { + const failed = props.environments.filter((environment) => environment.error !== null); + const stale = props.environments.filter((environment) => + props.merged.staleEnvironments.includes(environment.environmentId), + ); + const duplicateSources = props.merged.duplicateSources; + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !props.isPartial + ) { + return null; + } + + return ( + + {props.isPartial ? ( + + Some environments are still reporting. Totals are partial. + + ) : null} + {failed.map((environment) => ( + + {environment.label} could not report usage. + + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/usage/usageChartData.ts b/apps/mobile/src/features/usage/usageChartData.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7b996440ccb747630b0a7572abf5fc412782f56 --- /dev/null +++ b/apps/mobile/src/features/usage/usageChartData.ts @@ -0,0 +1,41 @@ +/** + * Shapes merged daily totals into the per-day provider stacks both chart + * implementations (Swift Charts on iOS, plain views elsewhere) render. + * + * @module usageChartData + */ +import type { UsageProviderKind } from "@t3tools/contracts"; +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { PROVIDER_ORDER } from "./usageProviders"; + +export type UsageChartMetric = "cost" | "tokens"; + +export interface UsageChartDay { + readonly day: string; + /** In {@link PROVIDER_ORDER}, i.e. bottom of the stack first. */ + readonly values: readonly { readonly provider: UsageProviderKind; readonly value: number }[]; + readonly total: number; +} + +/** One entry per day in the window, zero-filled where nothing happened. */ +export function buildChartDays( + days: readonly string[], + daily: readonly DailyTotals[], + metric: UsageChartMetric, +): readonly UsageChartDay[] { + const byDay = new Map(daily.map((totals) => [totals.day, totals])); + return days.map((day) => { + const totals = byDay.get(day); + const values = PROVIDER_ORDER.map((provider) => { + const entry = totals?.byProvider.get(provider); + const value = entry === undefined ? 0 : metric === "cost" ? entry.costUsd : entry.totalTokens; + return { provider, value }; + }); + return { + day, + values, + total: values.reduce((sum, entry) => sum + entry.value, 0), + }; + }); +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e2d027a9e359e152b4fdbeebb6cd19161be5307 --- /dev/null +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -0,0 +1,25 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useColorScheme } from "react-native"; + +/** + * Series and table order. The chart stacks providers from the bottom in this + * order, so it also fixes which band sits on top of the bars. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** + * Claude's brand orange holds in both themes; Codex is neutral and must flip + * with the theme or its bars vanish against the matching background. + */ +export function useProviderColors(): Record { + const scheme = useColorScheme(); + return { + claude: "#d97757", + codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + }; +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..91cefda07f599e9b902f19ff890631f942af3c96 --- /dev/null +++ b/apps/mobile/src/state/usage.ts @@ -0,0 +1,129 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * Mirror of `apps/web/src/state/usage.ts` over mobile's atom wiring; the merge + * rules themselves live in `@t3tools/shared/usageMerge`. + * + * @module state/usage + */ +import { useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`mobile-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so pull-to-refresh always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1a5b53904d95edfac212c67e69969b11c70030ac..b5b762aea73114da9e4f54d81635fafea88787b4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -13,7 +13,7 @@ import { formatTokens, formatUsd, makeWindow, -} from "../../usage/usageFormat"; +} from "@t3tools/shared/usageFormat"; import { ScrollArea } from "../ui/scroll-area"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1d5410ebad5a1074228c0b30d8a6a90b25514502..8f31d348eb04fe5ec130912182d035d3bae35eba 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,8 +1,8 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useMemo, useRef, useState } from "react"; -import type { DailyTotals } from "../../usage/usageMerge"; -import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; +import type { DailyTotals } from "@t3tools/shared/usageMerge"; +import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const VIEW_WIDTH = 960; diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 57114ade15214f02f28c329d53f457e4aedc93ce..9d65b6ad6004a3712f440c0d03618f7038b84f71 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -17,7 +17,7 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; diff --git a/apps/web/src/usage/usageFormat.ts b/apps/web/src/usage/usageFormat.ts deleted file mode 100644 index c7c216058375653594ea04232b292b9e3e731906..0000000000000000000000000000000000000000 --- a/apps/web/src/usage/usageFormat.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Display formatting for the usage page. - * - * @module usageFormat - */ -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; - -const CURRENCY = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -const INTEGER = new Intl.NumberFormat("en-US"); - -export function formatUsd(value: number): string { - return CURRENCY.format(value); -} - -export function formatCount(value: number): string { - return INTEGER.format(Math.round(value)); -} - -/** - * Compacts a token count to three significant figures with a unit suffix, so - * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). - */ -export function formatTokens(value: number): string { - const abs = Math.abs(value); - if (abs >= 1e12) return `${trim(value / 1e12)}T`; - if (abs >= 1e9) return `${trim(value / 1e9)}B`; - if (abs >= 1e6) return `${trim(value / 1e6)}M`; - if (abs >= 1e3) return `${trim(value / 1e3)}K`; - return INTEGER.format(Math.round(value)); -} - -function trim(value: number): string { - const abs = Math.abs(value); - const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; - return value.toFixed(digits).replace(/\.0+$/, ""); -} - -export function formatPercent(share: number, digits = 1): string { - return `${(share * 100).toFixed(digits)}%`; -} - -/** `2026-08-07` to `Aug 7`. */ -export function formatDayShort(day: string): string { - const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); - if (year === undefined || month === undefined || dayOfMonth === undefined) return day; - const MONTHS = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; -} - -/** Inclusive day list between two `YYYY-MM-DD` bounds. */ -export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { - const days: string[] = []; - const start = Date.parse(`${sinceDay}T00:00:00Z`); - const end = Date.parse(`${untilDay}T00:00:00Z`); - if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; - - for (let cursor = start; cursor <= end; cursor += 86_400_000) { - days.push(new Date(cursor).toISOString().slice(0, 10)); - } - return days; -} - -/** - * The window the page requests, expressed in the viewer's own time zone so days - * line up with what they actually experienced. - */ -export function makeWindow(days: number, now = new Date()): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - const untilDay = format.format(now); - // Subtracting fixed milliseconds from `now` lands on the wrong calendar day - // around a DST transition. Only "today" needs the zone; the window start is - // pure calendar arithmetic on that day, done in UTC where days are uniform. - const [year = 0, month = 1, dayOfMonth = 1] = untilDay - .split("-") - .map((part) => Number.parseInt(part, 10)); - const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); - return { - sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), - untilDay: UsageDay.make(untilDay), - timeZone, - }; -} diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts deleted file mode 100644 index 7e44631cf5db16df209ce7460431adbe123a22a6..0000000000000000000000000000000000000000 --- a/apps/web/src/usage/usageMerge.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - USAGE_CONTRACT_VERSION, - type EnvironmentId, - type UsageBucket, - type UsageDay, - type UsageProviderKind, - type UsageSummary, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { mergeUsage, type EnvironmentUsage } from "./usageMerge"; - -function bucket(overrides: Partial = {}): UsageBucket { - return { - day: "2026-08-07" as UsageDay, - provider: "claude", - model: "claude-fable-5", - totals: { - uncachedInputTokens: 100, - cachedInputTokens: 1000, - cacheCreationTokens: 10, - outputTokens: 50, - reasoningTokens: 0, - }, - costUsd: 10, - cacheSavingsUsd: 2, - costSource: "modelPriced", - records: 5, - unpricedRecords: 0, - sessions: 1, - ...overrides, - }; -} - -function summary( - buckets: readonly UsageBucket[], - sources: readonly { - provider: UsageProviderKind; - hostId: string; - homePath: string; - volumeId?: string; - distinctSessions?: number; - }[], - contractVersion: number = USAGE_CONTRACT_VERSION, -): UsageSummary { - return { - contractVersion, - readAt: "2026-08-07T00:00:00.000Z", - timeZone: "UTC", - sinceDay: "2026-08-01" as UsageDay, - untilDay: "2026-08-31" as UsageDay, - buckets, - sources: sources.map((source) => ({ - fingerprint: { - hostId: source.hostId, - provider: source.provider, - resolvedHomePath: source.homePath, - volumeId: source.volumeId ?? `vol-${source.hostId}`, - }, - status: "ok" as const, - scannedFiles: 1, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: source.distinctSessions ?? 1, - message: null, - })), - pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, - scanDurationMs: 1, - }; -} - -function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { - return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; -} - -describe("mergeUsage", () => { - it("sums environments that read different transcript directories", () => { - const merged = mergeUsage( - [ - environment( - "env-a", - summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]), - ), - environment( - "env-b", - summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]), - ), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.costUsd).toBe(20); - expect(merged.records).toBe(10); - expect(merged.duplicateSources).toHaveLength(0); - }); - - it("counts a shared transcript directory once", () => { - // Two worktree servers on one machine resolve the same provider home. - const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; - const merged = mergeUsage( - [ - environment("env-a", summary([bucket()], [shared])), - environment("env-b", summary([bucket()], [shared])), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.costUsd).toBe(10); - expect(merged.records).toBe(5); - expect(merged.sessions).toBe(1); - expect(merged.duplicateSources).toHaveLength(1); - expect(merged.contributingEnvironments).toEqual(["env-a"]); - }); - - it("drops only the duplicated provider, keeping the environment's other one", () => { - const sharedClaude = { - provider: "claude" as const, - hostId: "mac", - homePath: "/home/theo/.claude", - }; - const merged = mergeUsage( - [ - environment("env-a", summary([bucket()], [sharedClaude])), - environment( - "env-b", - summary( - [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], - [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], - ), - ), - ], - USAGE_CONTRACT_VERSION, - ); - - // env-b's claude bucket is dropped, its codex bucket survives. - expect(merged.costUsd).toBe(14); - expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ - "claude", - "codex", - ]); - }); - - it("excludes an environment reporting an older contract version", () => { - const merged = mergeUsage( - [ - environment( - "env-a", - summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]), - ), - environment( - "env-b", - summary( - [bucket()], - [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 1, - ), - ), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.costUsd).toBe(10); - expect(merged.staleEnvironments).toEqual(["env-b"]); - }); - - it("derives provider shares and cost quality", () => { - const merged = mergeUsage( - [ - environment( - "env-a", - summary( - [ - bucket({ costUsd: 75 }), - bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), - ], - [ - { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, - { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, - ], - ), - ), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.providers[0]?.provider).toBe("claude"); - expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); - expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); - expect(merged.costQuality.cacheSavingsUsd).toBe(4); - }); - - it("keeps two machines apart when hostname and home path collide", () => { - // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make - // one machine's usage vanish. Filesystem identity separates them. - const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; - const merged = mergeUsage( - [ - environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])), - environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.costUsd).toBe(20); - expect(merged.duplicateSources).toHaveLength(0); - }); - - it("still collapses two servers reading the same directory", () => { - const same = { - provider: "claude" as const, - hostId: "mac", - homePath: "/Users/theo/.claude", - volumeId: "16777220:1234", - }; - const merged = mergeUsage( - [ - environment("env-a", summary([bucket()], [same])), - environment("env-b", summary([bucket()], [same])), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.costUsd).toBe(10); - expect(merged.duplicateSources).toHaveLength(1); - }); - - it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { - // One session that spans two days appears in two buckets. Summing bucket - // sessions would say 2; the source's distinct count says 1. - const merged = mergeUsage( - [ - environment( - "env-a", - summary( - [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })], - [ - { - provider: "claude", - hostId: "mac", - homePath: "/a/.claude", - distinctSessions: 1, - }, - ], - ), - ), - ], - USAGE_CONTRACT_VERSION, - ); - - expect(merged.sessions).toBe(1); - }); - - it("returns empty totals with no environments", () => { - const merged = mergeUsage([], USAGE_CONTRACT_VERSION); - expect(merged.costUsd).toBe(0); - expect(merged.daily).toHaveLength(0); - }); -}); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts deleted file mode 100644 index fd73c0a31c024b2faa920ece88ee1cb132b76396..0000000000000000000000000000000000000000 --- a/apps/web/src/usage/usageMerge.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Merges per-environment usage summaries into the single view the page renders. - * - * Pure, so the de-duplication and derivation rules can be tested without a - * connected environment. - * - * @module usageMerge - */ -import type { - EnvironmentId, - UsageBucket, - UsageProviderKind, - UsageSourceFingerprint, - UsageSummary, -} from "@t3tools/contracts"; - -export interface EnvironmentUsage { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly summary: UsageSummary; -} - -export interface ProviderTotals { - readonly provider: UsageProviderKind; - readonly costUsd: number; - readonly totalTokens: number; - readonly records: number; - readonly costShare: number; - readonly tokenShare: number; -} - -export interface ModelTotals { - readonly model: string; - readonly provider: UsageProviderKind; - readonly costUsd: number; - readonly totalTokens: number; - readonly records: number; - readonly costShare: number; -} - -export interface DailyTotals { - readonly day: string; - readonly costUsd: number; - readonly totalTokens: number; - readonly byProvider: ReadonlyMap; -} - -export interface CostQuality { - readonly providerReportedShare: number; - readonly modelPricedShare: number; - readonly unpricedShare: number; - readonly cacheSavingsUsd: number; -} - -export interface MergedUsage { - readonly costUsd: number; - readonly uncachedInputTokens: number; - readonly cachedInputTokens: number; - readonly cacheCreationTokens: number; - readonly outputTokens: number; - readonly reasoningTokens: number; - readonly totalTokens: number; - readonly records: number; - readonly sessions: number; - readonly providers: readonly ProviderTotals[]; - readonly models: readonly ModelTotals[]; - readonly daily: readonly DailyTotals[]; - readonly costQuality: CostQuality; - /** Environments whose data was dropped as a duplicate of another's. */ - readonly duplicateSources: readonly string[]; - readonly contributingEnvironments: readonly EnvironmentId[]; - readonly staleEnvironments: readonly EnvironmentId[]; -} - -/** - * Two sources are the same physical transcript directory only when host, - * provider, path and filesystem identity all agree. - * - * `volumeId` is what stops two machines that happen to share a hostname and a - * home path, which is every Mac in a fleet, from collapsing into one source and - * having one of them silently dropped. - */ -function fingerprintKey(fingerprint: UsageSourceFingerprint): string { - return [ - fingerprint.hostId, - fingerprint.provider, - fingerprint.resolvedHomePath, - fingerprint.volumeId, - ].join(" "); -} - -/** - * Decides which environment owns each physical transcript directory. - * - * Several environments on one machine (worktree servers, for instance) resolve - * the same provider home and would otherwise double count every token. The - * first environment in a stable order claims a fingerprint; the rest have that - * provider's buckets dropped. Environments are sorted by id so the winner does - * not change between renders. - */ -function claimSources(environments: readonly EnvironmentUsage[]): { - readonly ownerByFingerprint: ReadonlyMap; - readonly duplicates: readonly string[]; -} { - const ownerByFingerprint = new Map(); - const duplicates: string[] = []; - - const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); - - for (const environment of ordered) { - for (const source of environment.summary.sources) { - if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.has(key)) { - duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); - continue; - } - ownerByFingerprint.set(key, environment.environmentId); - } - } - - return { ownerByFingerprint, duplicates }; -} - -/** Sources this environment owns after fingerprint claims, plus their buckets. */ -function ownedContribution( - environment: EnvironmentUsage, - ownerByFingerprint: ReadonlyMap, -): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { - const ownedProviders = new Set(); - let sessions = 0; - for (const source of environment.summary.sources) { - if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.get(key) === environment.environmentId) { - ownedProviders.add(source.fingerprint.provider); - // Distinct within a directory. Summing per-bucket session counts instead - // would count a session once per day and model it spans. - sessions += source.distinctSessions; - } - } - return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessions, - }; -} - -function bucketTokens(bucket: UsageBucket): number { - // reasoningTokens is a subset of outputTokens and must not be added again. - return ( - bucket.totals.uncachedInputTokens + - bucket.totals.cachedInputTokens + - bucket.totals.cacheCreationTokens + - bucket.totals.outputTokens - ); -} - -const EMPTY_MERGED: MergedUsage = { - costUsd: 0, - uncachedInputTokens: 0, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - totalTokens: 0, - records: 0, - sessions: 0, - providers: [], - models: [], - daily: [], - costQuality: { - providerReportedShare: 0, - modelPricedShare: 0, - unpricedShare: 0, - cacheSavingsUsd: 0, - }, - duplicateSources: [], - contributingEnvironments: [], - staleEnvironments: [], -}; - -/** - * Merges every connected environment's summary. - * - * `expectedContractVersion` guards against an environment running older server - * code: rather than blocking the page, its data is excluded and its id is - * reported so the UI can say coverage is partial. - */ -export function mergeUsage( - environments: readonly EnvironmentUsage[], - expectedContractVersion: number, -): MergedUsage { - if (environments.length === 0) return EMPTY_MERGED; - - const current: EnvironmentUsage[] = []; - const staleEnvironments: EnvironmentId[] = []; - for (const environment of environments) { - if (environment.summary.contractVersion === expectedContractVersion) { - current.push(environment); - } else { - staleEnvironments.push(environment.environmentId); - } - } - - const { ownerByFingerprint, duplicates } = claimSources(current); - - let costUsd = 0; - let uncachedInputTokens = 0; - let cachedInputTokens = 0; - let cacheCreationTokens = 0; - let outputTokens = 0; - let reasoningTokens = 0; - let records = 0; - let sessions = 0; - let cacheSavingsUsd = 0; - let providerReportedRecords = 0; - let unpricedRecords = 0; - - const providerAccumulator = new Map< - UsageProviderKind, - { costUsd: number; totalTokens: number; records: number } - >(); - const modelAccumulator = new Map< - string, - { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } - >(); - const dailyAccumulator = new Map< - string, - { - costUsd: number; - totalTokens: number; - byProvider: Map; - } - >(); - const contributingEnvironments: EnvironmentId[] = []; - - for (const environment of current) { - const { buckets, sessions: environmentSessions } = ownedContribution( - environment, - ownerByFingerprint, - ); - if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - sessions += environmentSessions; - - for (const bucket of buckets) { - const tokens = bucketTokens(bucket); - - costUsd += bucket.costUsd; - cacheSavingsUsd += bucket.cacheSavingsUsd; - uncachedInputTokens += bucket.totals.uncachedInputTokens; - cachedInputTokens += bucket.totals.cachedInputTokens; - cacheCreationTokens += bucket.totals.cacheCreationTokens; - outputTokens += bucket.totals.outputTokens; - reasoningTokens += bucket.totals.reasoningTokens; - records += bucket.records; - unpricedRecords += bucket.unpricedRecords; - if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; - - const provider = providerAccumulator.get(bucket.provider) ?? { - costUsd: 0, - totalTokens: 0, - records: 0, - }; - provider.costUsd += bucket.costUsd; - provider.totalTokens += tokens; - provider.records += bucket.records; - providerAccumulator.set(bucket.provider, provider); - - const modelKey = `${bucket.provider} ${bucket.model}`; - const model = modelAccumulator.get(modelKey) ?? { - provider: bucket.provider, - costUsd: 0, - totalTokens: 0, - records: 0, - }; - model.costUsd += bucket.costUsd; - model.totalTokens += tokens; - model.records += bucket.records; - modelAccumulator.set(modelKey, model); - - const day = dailyAccumulator.get(bucket.day) ?? { - costUsd: 0, - totalTokens: 0, - byProvider: new Map(), - }; - day.costUsd += bucket.costUsd; - day.totalTokens += tokens; - const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; - dayProvider.costUsd += bucket.costUsd; - dayProvider.totalTokens += tokens; - day.byProvider.set(bucket.provider, dayProvider); - dailyAccumulator.set(bucket.day, day); - } - } - - const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; - - const providers: ProviderTotals[] = [...providerAccumulator.entries()] - .map(([provider, totals]) => ({ - provider, - costUsd: totals.costUsd, - totalTokens: totals.totalTokens, - records: totals.records, - costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, - tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, - })) - .sort((a, b) => b.costUsd - a.costUsd); - - const models: ModelTotals[] = [...modelAccumulator.entries()] - .map(([key, totals]) => ({ - model: key.slice(key.indexOf(" ") + 1), - provider: totals.provider, - costUsd: totals.costUsd, - totalTokens: totals.totalTokens, - records: totals.records, - costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, - })) - .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); - - const daily: DailyTotals[] = [...dailyAccumulator.entries()] - .map(([day, totals]) => ({ - day, - costUsd: totals.costUsd, - totalTokens: totals.totalTokens, - byProvider: totals.byProvider, - })) - .sort((a, b) => a.day.localeCompare(b.day)); - - return { - costUsd, - uncachedInputTokens, - cachedInputTokens, - cacheCreationTokens, - outputTokens, - reasoningTokens, - totalTokens, - records, - sessions, - providers, - models, - daily, - costQuality: { - providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, - unpricedShare: records === 0 ? 0 : unpricedRecords / records, - modelPricedShare: - records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, - cacheSavingsUsd, - }, - duplicateSources: duplicates, - contributingEnvironments, - staleEnvironments, - }; -} diff --git a/packages/shared/package.json b/packages/shared/package.json index 1cffab6c25330b7084b48c5dee7f1a5710e19314..f669bd0a452cb953781e0153fd3280707591753c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -210,6 +210,14 @@ "./devProxy": { "types": "./src/devProxy.ts", "import": "./src/devProxy.ts" + }, + "./usageMerge": { + "types": "./src/usageMerge.ts", + "import": "./src/usageMerge.ts" + }, + "./usageFormat": { + "types": "./src/usageFormat.ts", + "import": "./src/usageFormat.ts" } }, "scripts": { diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts new file mode 100644 index 0000000000000000000000000000000000000000..21e11fee743d607a225743ad744acccdcfb79a8e --- /dev/null +++ b/packages/shared/src/usageFormat.ts @@ -0,0 +1,108 @@ +// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl. +/** + * Display formatting for the usage page. + * + * @module usageFormat + */ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const CURRENCY = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const INTEGER = new Intl.NumberFormat("en-US"); + +export function formatUsd(value: number): string { + return CURRENCY.format(value); +} + +export function formatCount(value: number): string { + return INTEGER.format(Math.round(value)); +} + +/** + * Compacts a token count to three significant figures with a unit suffix, so + * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). + */ +export function formatTokens(value: number): string { + const abs = Math.abs(value); + if (abs >= 1e12) return `${trim(value / 1e12)}T`; + if (abs >= 1e9) return `${trim(value / 1e9)}B`; + if (abs >= 1e6) return `${trim(value / 1e6)}M`; + if (abs >= 1e3) return `${trim(value / 1e3)}K`; + return INTEGER.format(Math.round(value)); +} + +function trim(value: number): string { + const abs = Math.abs(value); + const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; + return value.toFixed(digits).replace(/\.0+$/, ""); +} + +export function formatPercent(share: number, digits = 1): string { + return `${(share * 100).toFixed(digits)}%`; +} + +/** `2026-08-07` to `Aug 7`. */ +export function formatDayShort(day: string): string { + const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); + if (year === undefined || month === undefined || dayOfMonth === undefined) return day; + const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; +} + +/** Inclusive day list between two `YYYY-MM-DD` bounds. */ +export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { + const days: string[] = []; + const start = Date.parse(`${sinceDay}T00:00:00Z`); + const end = Date.parse(`${untilDay}T00:00:00Z`); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; + + for (let cursor = start; cursor <= end; cursor += 86_400_000) { + days.push(new Date(cursor).toISOString().slice(0, 10)); + } + return days; +} + +/** + * The window the page requests, expressed in the viewer's own time zone so days + * line up with what they actually experienced. + */ +export function makeWindow(days: number, now = new Date()): UsageSummaryInput { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const untilDay = format.format(now); + // Subtracting fixed milliseconds from `now` lands on the wrong calendar day + // around a DST transition. Only "today" needs the zone; the window start is + // pure calendar arithmetic on that day, done in UTC where days are uniform. + const [year = 0, month = 1, dayOfMonth = 1] = untilDay + .split("-") + .map((part) => Number.parseInt(part, 10)); + const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); + return { + sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), + untilDay: UsageDay.make(untilDay), + timeZone, + }; +} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c377a33f97eb4f2ad5bd5e29ab126344f9a39cc --- /dev/null +++ b/packages/shared/src/usageMerge.test.ts @@ -0,0 +1,258 @@ +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; + +function bucket(overrides: Partial = {}): UsageBucket { + return { + day: "2026-08-07" as UsageDay, + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "modelPriced", + records: 5, + unpricedRecords: 0, + sessions: 1, + ...overrides, + }; +} + +function summary( + buckets: readonly UsageBucket[], + sources: readonly { + provider: UsageProviderKind; + hostId: string; + homePath: string; + volumeId?: string; + distinctSessions?: number; + }[], + contractVersion: number = USAGE_CONTRACT_VERSION, +): UsageSummary { + return { + contractVersion, + readAt: "2026-08-07T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-31" as UsageDay, + buckets, + sources: sources.map((source) => ({ + fingerprint: { + hostId: source.hostId, + provider: source.provider, + resolvedHomePath: source.homePath, + volumeId: source.volumeId ?? `vol-${source.hostId}`, + }, + status: "ok" as const, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: source.distinctSessions ?? 1, + message: null, + })), + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + scanDurationMs: 1, + }; +} + +function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { + return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; +} + +describe("mergeUsage", () => { + it("sums environments that read different transcript directories", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]), + ), + environment( + "env-b", + summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.records).toBe(10); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("counts a shared transcript directory once", () => { + // Two worktree servers on one machine resolve the same provider home. + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [shared])), + environment("env-b", summary([bucket()], [shared])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.duplicateSources).toHaveLength(1); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + }); + + it("drops only the duplicated provider, keeping the environment's other one", () => { + const sharedClaude = { + provider: "claude" as const, + hostId: "mac", + homePath: "/home/theo/.claude", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [sharedClaude])), + environment( + "env-b", + summary( + [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], + [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + // env-b's claude bucket is dropped, its codex bucket survives. + expect(merged.costUsd).toBe(14); + expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ + "claude", + "codex", + ]); + }); + + it("excludes an environment reporting an older contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]), + ), + environment( + "env-b", + summary( + [bucket()], + [{ provider: "claude", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); + }); + + it("derives provider shares and cost quality", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]?.provider).toBe("claude"); + expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); + expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); + expect(merged.costQuality.cacheSavingsUsd).toBe(4); + }); + + it("keeps two machines apart when hostname and home path collide", () => { + // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make + // one machine's usage vanish. Filesystem identity separates them. + const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])), + environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("still collapses two servers reading the same directory", () => { + const same = { + provider: "claude" as const, + hostId: "mac", + homePath: "/Users/theo/.claude", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [same])), + environment("env-b", summary([bucket()], [same])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.duplicateSources).toHaveLength(1); + }); + + it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { + // One session that spans two days appears in two buckets. Summing bucket + // sessions would say 2; the source's distinct count says 1. + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })], + [ + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + distinctSessions: 1, + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.sessions).toBe(1); + }); + + it("returns empty totals with no environments", () => { + const merged = mergeUsage([], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.daily).toHaveLength(0); + }); +}); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd73c0a31c024b2faa920ece88ee1cb132b76396 --- /dev/null +++ b/packages/shared/src/usageMerge.ts @@ -0,0 +1,353 @@ +/** + * Merges per-environment usage summaries into the single view the page renders. + * + * Pure, so the de-duplication and derivation rules can be tested without a + * connected environment. + * + * @module usageMerge + */ +import type { + EnvironmentId, + UsageBucket, + UsageProviderKind, + UsageSourceFingerprint, + UsageSummary, +} from "@t3tools/contracts"; + +export interface EnvironmentUsage { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly summary: UsageSummary; +} + +export interface ProviderTotals { + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; + readonly tokenShare: number; +} + +export interface ModelTotals { + readonly model: string; + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; +} + +export interface DailyTotals { + readonly day: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly byProvider: ReadonlyMap; +} + +export interface CostQuality { + readonly providerReportedShare: number; + readonly modelPricedShare: number; + readonly unpricedShare: number; + readonly cacheSavingsUsd: number; +} + +export interface MergedUsage { + readonly costUsd: number; + readonly uncachedInputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + readonly totalTokens: number; + readonly records: number; + readonly sessions: number; + readonly providers: readonly ProviderTotals[]; + readonly models: readonly ModelTotals[]; + readonly daily: readonly DailyTotals[]; + readonly costQuality: CostQuality; + /** Environments whose data was dropped as a duplicate of another's. */ + readonly duplicateSources: readonly string[]; + readonly contributingEnvironments: readonly EnvironmentId[]; + readonly staleEnvironments: readonly EnvironmentId[]; +} + +/** + * Two sources are the same physical transcript directory only when host, + * provider, path and filesystem identity all agree. + * + * `volumeId` is what stops two machines that happen to share a hostname and a + * home path, which is every Mac in a fleet, from collapsing into one source and + * having one of them silently dropped. + */ +function fingerprintKey(fingerprint: UsageSourceFingerprint): string { + return [ + fingerprint.hostId, + fingerprint.provider, + fingerprint.resolvedHomePath, + fingerprint.volumeId, + ].join(" "); +} + +/** + * Decides which environment owns each physical transcript directory. + * + * Several environments on one machine (worktree servers, for instance) resolve + * the same provider home and would otherwise double count every token. The + * first environment in a stable order claims a fingerprint; the rest have that + * provider's buckets dropped. Environments are sorted by id so the winner does + * not change between renders. + */ +function claimSources(environments: readonly EnvironmentUsage[]): { + readonly ownerByFingerprint: ReadonlyMap; + readonly duplicates: readonly string[]; +} { + const ownerByFingerprint = new Map(); + const duplicates: string[] = []; + + const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); + } + } + + return { ownerByFingerprint, duplicates }; +} + +/** Sources this environment owns after fingerprint claims, plus their buckets. */ +function ownedContribution( + environment: EnvironmentUsage, + ownerByFingerprint: ReadonlyMap, +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { + const ownedProviders = new Set(); + let sessions = 0; + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.get(key) === environment.environmentId) { + ownedProviders.add(source.fingerprint.provider); + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessions += source.distinctSessions; + } + } + return { + buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + sessions, + }; +} + +function bucketTokens(bucket: UsageBucket): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + ); +} + +const EMPTY_MERGED: MergedUsage = { + costUsd: 0, + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + records: 0, + sessions: 0, + providers: [], + models: [], + daily: [], + costQuality: { + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0, + }, + duplicateSources: [], + contributingEnvironments: [], + staleEnvironments: [], +}; + +/** + * Merges every connected environment's summary. + * + * `expectedContractVersion` guards against an environment running older server + * code: rather than blocking the page, its data is excluded and its id is + * reported so the UI can say coverage is partial. + */ +export function mergeUsage( + environments: readonly EnvironmentUsage[], + expectedContractVersion: number, +): MergedUsage { + if (environments.length === 0) return EMPTY_MERGED; + + const current: EnvironmentUsage[] = []; + const staleEnvironments: EnvironmentId[] = []; + for (const environment of environments) { + if (environment.summary.contractVersion === expectedContractVersion) { + current.push(environment); + } else { + staleEnvironments.push(environment.environmentId); + } + } + + const { ownerByFingerprint, duplicates } = claimSources(current); + + let costUsd = 0; + let uncachedInputTokens = 0; + let cachedInputTokens = 0; + let cacheCreationTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let records = 0; + let sessions = 0; + let cacheSavingsUsd = 0; + let providerReportedRecords = 0; + let unpricedRecords = 0; + + const providerAccumulator = new Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number } + >(); + const modelAccumulator = new Map< + string, + { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + >(); + const dailyAccumulator = new Map< + string, + { + costUsd: number; + totalTokens: number; + byProvider: Map; + } + >(); + const contributingEnvironments: EnvironmentId[] = []; + + for (const environment of current) { + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); + if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + sessions += environmentSessions; + + for (const bucket of buckets) { + const tokens = bucketTokens(bucket); + + costUsd += bucket.costUsd; + cacheSavingsUsd += bucket.cacheSavingsUsd; + uncachedInputTokens += bucket.totals.uncachedInputTokens; + cachedInputTokens += bucket.totals.cachedInputTokens; + cacheCreationTokens += bucket.totals.cacheCreationTokens; + outputTokens += bucket.totals.outputTokens; + reasoningTokens += bucket.totals.reasoningTokens; + records += bucket.records; + unpricedRecords += bucket.unpricedRecords; + if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + + const provider = providerAccumulator.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + }; + provider.costUsd += bucket.costUsd; + provider.totalTokens += tokens; + provider.records += bucket.records; + providerAccumulator.set(bucket.provider, provider); + + const modelKey = `${bucket.provider} ${bucket.model}`; + const model = modelAccumulator.get(modelKey) ?? { + provider: bucket.provider, + costUsd: 0, + totalTokens: 0, + records: 0, + }; + model.costUsd += bucket.costUsd; + model.totalTokens += tokens; + model.records += bucket.records; + modelAccumulator.set(modelKey, model); + + const day = dailyAccumulator.get(bucket.day) ?? { + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; + day.costUsd += bucket.costUsd; + day.totalTokens += tokens; + const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + dayProvider.costUsd += bucket.costUsd; + dayProvider.totalTokens += tokens; + day.byProvider.set(bucket.provider, dayProvider); + dailyAccumulator.set(bucket.day, day); + } + } + + const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; + + const providers: ProviderTotals[] = [...providerAccumulator.entries()] + .map(([provider, totals]) => ({ + provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, + })) + .sort((a, b) => b.costUsd - a.costUsd); + + const models: ModelTotals[] = [...modelAccumulator.entries()] + .map(([key, totals]) => ({ + model: key.slice(key.indexOf(" ") + 1), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + + const daily: DailyTotals[] = [...dailyAccumulator.entries()] + .map(([day, totals]) => ({ + day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider, + })) + .sort((a, b) => a.day.localeCompare(b.day)); + + return { + costUsd, + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + totalTokens, + records, + sessions, + providers, + models, + daily, + costQuality: { + providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, + unpricedShare: records === 0 ? 0 : unpricedRecords / records, + modelPricedShare: + records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, + cacheSavingsUsd, + }, + duplicateSources: duplicates, + contributingEnvironments, + staleEnvironments, + }; +}