| author | |
| committer | |
| log | 8c85b4933fb7202c2d48e4c8f8ea0e1cfdbcdc29 |
| tree | d59ec08ee4022bf3c2a09731548caf70494277ab |
| parent | 3c0665543f0e31db1e3425d4fcf9f4aea295a8ce |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 434 insertions(+), 346 deletions(-)
apps/web/src/components/WorkspaceBreadcrumb.tsx+2-2| ... | @@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({ | ... | @@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({ |
| 45 | ); | 45 | ); |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | export function WorkspaceBreadcrumbSeparator() { | 48 | export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) { |
| 49 | return ( | 49 | return ( |
| 50 | <li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted"> | 50 | <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}> |
| 51 | / | 51 | / |
| 52 | </li> | 52 | </li> |
| 53 | ); | 53 | ); |
apps/web/src/components/WorkspacePageContainer.tsx created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import type { ComponentPropsWithoutRef } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../lib/utils"; | ||
| 4 | |||
| 5 | export type WorkspacePageWidth = "readable" | "wide" | "expanded"; | ||
| 6 | |||
| 7 | const WIDTH_CLASS: Record<WorkspacePageWidth, string> = { | ||
| 8 | readable: "max-w-4xl", | ||
| 9 | wide: "max-w-5xl", | ||
| 10 | expanded: "max-w-6xl", | ||
| 11 | }; | ||
| 12 | |||
| 13 | /** Shared content frame for workspace pages. */ | ||
| 14 | export function WorkspacePageContainer({ | ||
| 15 | width = "readable", | ||
| 16 | className, | ||
| 17 | ...props | ||
| 18 | }: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { | ||
| 19 | return ( | ||
| 20 | <div | ||
| 21 | className={cn( | ||
| 22 | "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6", | ||
| 23 | WIDTH_CLASS[width], | ||
| 24 | className, | ||
| 25 | )} | ||
| 26 | {...props} | ||
| 27 | /> | ||
| 28 | ); | ||
| 29 | } | ||
apps/web/src/components/settings/settingsLayout.tsx+4-17| ... | @@ -12,17 +12,10 @@ import { | ... | @@ -12,17 +12,10 @@ import { |
| 12 | } from "react"; | 12 | } from "react"; |
| 13 | 13 | ||
| 14 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; | ||
| 15 | import { Button } from "../ui/button"; | 16 | import { Button } from "../ui/button"; |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 17 | 18 | ||
| 18 | type SettingsPageWidth = "readable" | "wide" | "expanded"; | ||
| 19 | |||
| 20 | const SETTINGS_PAGE_WIDTH_CLASS: Record<SettingsPageWidth, string> = { | ||
| 21 | readable: "max-w-4xl", | ||
| 22 | wide: "max-w-5xl", | ||
| 23 | expanded: "max-w-6xl", | ||
| 24 | }; | ||
| 25 | |||
| 26 | interface SettingsSearchTargetContextValue { | 19 | interface SettingsSearchTargetContextValue { |
| 27 | readonly targetId: string | null; | 20 | readonly targetId: string | null; |
| 28 | readonly onTargetHandled: () => void; | 21 | readonly onTargetHandled: () => void; |
| ... | @@ -245,7 +238,7 @@ export function SettingsPageContainer({ | ... | @@ -245,7 +238,7 @@ export function SettingsPageContainer({ |
| 245 | }: { | 238 | }: { |
| 246 | children: ReactNode; | 239 | children: ReactNode; |
| 247 | className?: string; | 240 | className?: string; |
| 248 | width?: SettingsPageWidth; | 241 | width?: WorkspacePageWidth; |
| 249 | }) { | 242 | }) { |
| 250 | const navigate = useNavigate(); | 243 | const navigate = useNavigate(); |
| 251 | const hash = useLocation({ select: (location) => location.hash }); | 244 | const hash = useLocation({ select: (location) => location.hash }); |
| ... | @@ -260,15 +253,9 @@ export function SettingsPageContainer({ | ... | @@ -260,15 +253,9 @@ export function SettingsPageContainer({ |
| 260 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]" | 253 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]" |
| 261 | data-settings-page-scroll | 254 | data-settings-page-scroll |
| 262 | > | 255 | > |
| 263 | <div | 256 | <WorkspacePageContainer width={width} className={cn("gap-12", className)}> |
| 264 | className={cn( | ||
| 265 | "mx-auto flex w-full flex-col gap-12 px-5 pt-6 pb-12 sm:px-6", | ||
| 266 | SETTINGS_PAGE_WIDTH_CLASS[width], | ||
| 267 | className, | ||
| 268 | )} | ||
| 269 | > | ||
| 270 | {children} | 257 | {children} |
| 271 | </div> | 258 | </WorkspacePageContainer> |
| 272 | </div> | 259 | </div> |
| 273 | </SettingsSearchTargetProvider> | 260 | </SettingsSearchTargetProvider> |
| 274 | ); | 261 | ); |
apps/web/src/components/ui/toggle-group.test.tsx created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { Toggle, ToggleGroup } from "./toggle-group"; | ||
| 5 | |||
| 6 | describe("toggle group segmented defaults", () => { | ||
| 7 | it("derives the segmented item size from the variant", () => { | ||
| 8 | const html = renderToStaticMarkup( | ||
| 9 | <ToggleGroup variant="segmented" value={["a"]}> | ||
| 10 | <Toggle value="a">A</Toggle> | ||
| 11 | </ToggleGroup>, | ||
| 12 | ); | ||
| 13 | |||
| 14 | expect(html.match(/data-size="segmented"/g)).toHaveLength(2); | ||
| 15 | expect(html).toContain("h-6"); | ||
| 16 | expect(html).toContain("dark:hover:bg-input/32"); | ||
| 17 | expect(html).toContain("dark:data-pressed:bg-input/72"); | ||
| 18 | }); | ||
| 19 | }); | ||
apps/web/src/components/ui/toggle-group.tsx+8-6| ... | @@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant | ... | @@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant |
| 17 | function ToggleGroup({ | 17 | function ToggleGroup({ |
| 18 | className, | 18 | className, |
| 19 | variant = "default", | 19 | variant = "default", |
| 20 | size = "default", | 20 | size = variant === "segmented" ? "segmented" : "default", |
| 21 | orientation = "horizontal", | 21 | orientation = "horizontal", |
| 22 | children, | 22 | children, |
| 23 | ...props | 23 | ...props |
| ... | @@ -30,11 +30,13 @@ function ToggleGroup({ | ... | @@ -30,11 +30,13 @@ function ToggleGroup({ |
| 30 | orientation === "horizontal" | 30 | orientation === "horizontal" |
| 31 | ? "*:pointer-coarse:after:min-w-auto" | 31 | ? "*:pointer-coarse:after:min-w-auto" |
| 32 | : "*:pointer-coarse:after:min-h-auto", | 32 | : "*:pointer-coarse:after:min-h-auto", |
| 33 | variant === "default" | 33 | variant === "segmented" |
| 34 | ? "gap-0.5" | 34 | ? "gap-0.5 rounded-lg bg-input/40 p-0.5" |
| 35 | : orientation === "horizontal" | 35 | : variant === "default" |
| 36 | ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" | 36 | ? "gap-0.5" |
| 37 | : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", | 37 | : orientation === "horizontal" |
| 38 | ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" | ||
| 39 | : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", | ||
| 38 | className, | 40 | className, |
| 39 | )} | 41 | )} |
| 40 | data-size={size} | 42 | data-size={size} |
apps/web/src/components/ui/toggle.tsx+4| ... | @@ -18,6 +18,8 @@ const toggleVariants = cva( | ... | @@ -18,6 +18,8 @@ const toggleVariants = cva( |
| 18 | "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", | 18 | "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", |
| 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", | 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", |
| 20 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", | 20 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", |
| 21 | segmented: | ||
| 22 | "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", | ||
| 21 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", | 23 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", |
| 22 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", | 24 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", |
| 23 | }, | 25 | }, |
| ... | @@ -27,6 +29,8 @@ const toggleVariants = cva( | ... | @@ -27,6 +29,8 @@ const toggleVariants = cva( |
| 27 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", | 29 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", |
| 28 | outline: | 30 | outline: |
| 29 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", | 31 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", |
| 32 | segmented: | ||
| 33 | "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72", | ||
| 30 | }, | 34 | }, |
| 31 | }, | 35 | }, |
| 32 | }, | 36 | }, |
apps/web/src/components/usage/UsagePage.tsx+218-245| ... | @@ -19,12 +19,19 @@ import { | ... | @@ -19,12 +19,19 @@ import { |
| 19 | formatUsd, | 19 | formatUsd, |
| 20 | makeWindow, | 20 | makeWindow, |
| 21 | } from "@t3tools/shared/usageFormat"; | 21 | } from "@t3tools/shared/usageFormat"; |
| 22 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 23 | import { Button } from "../ui/button"; | 22 | import { Button } from "../ui/button"; |
| 23 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 24 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 24 | import { SidebarInset } from "../ui/sidebar"; | 25 | import { SidebarInset } from "../ui/sidebar"; |
| 25 | import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; | 26 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 26 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | 27 | import { |
| 27 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 28 | WorkspaceBreadcrumb, |
| 29 | WorkspaceBreadcrumbItem, | ||
| 30 | WorkspaceBreadcrumbSeparator, | ||
| 31 | } from "../WorkspaceBreadcrumb"; | ||
| 32 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | ||
| 33 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; | ||
| 34 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | ||
| 28 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; | 35 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 29 | 36 | ||
| 30 | const WINDOW_OPTIONS = [ | 37 | const WINDOW_OPTIONS = [ |
| ... | @@ -68,21 +75,6 @@ export function UsagePage() { | ... | @@ -68,21 +75,6 @@ export function UsagePage() { |
| 68 | [isPast24Hours, merged.daily, merged.hourly], | 75 | [isPast24Hours, merged.daily, merged.hourly], |
| 69 | ); | 76 | ); |
| 70 | 77 | ||
| 71 | // Ranked by whatever the toggle is showing, so the bars always descend. | ||
| 72 | const orderedProviders = useMemo( | ||
| 73 | () => | ||
| 74 | merged.providers.toSorted((a, b) => | ||
| 75 | metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, | ||
| 76 | ), | ||
| 77 | [merged.providers, metric], | ||
| 78 | ); | ||
| 79 | |||
| 80 | const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( | ||
| 81 | (period) => period.totalTokens > 0, | ||
| 82 | ).length; | ||
| 83 | const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; | ||
| 84 | const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; | ||
| 85 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; | ||
| 86 | const selectWindow = (days: number) => { | 78 | const selectWindow = (days: number) => { |
| 87 | setWindowSelection({ | 79 | setWindowSelection({ |
| 88 | days, | 80 | days, |
| ... | @@ -102,78 +94,113 @@ export function UsagePage() { | ... | @@ -102,78 +94,113 @@ export function UsagePage() { |
| 102 | setWindowSelection({ days: windowDays, window: nextWindow }); | 94 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 103 | } | 95 | } |
| 104 | }; | 96 | }; |
| 97 | const windowLabel = | ||
| 98 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | ||
| 99 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | ||
| 100 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; | ||
| 101 | const topbarContent = ( | ||
| 102 | <div className="flex w-full min-w-0 items-center gap-3"> | ||
| 103 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0"> | ||
| 104 | <WorkspaceBreadcrumbItem current> | ||
| 105 | <h1>Usage</h1> | ||
| 106 | </WorkspaceBreadcrumbItem> | ||
| 107 | <WorkspaceBreadcrumbSeparator className="hidden md:flex" /> | ||
| 108 | <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex"> | ||
| 109 | <span className="truncate">{windowLabel}</span> | ||
| 110 | </WorkspaceBreadcrumbItem> | ||
| 111 | </WorkspaceBreadcrumb> | ||
| 112 | <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex"> | ||
| 113 | <ToggleGroup | ||
| 114 | aria-label="Usage metric" | ||
| 115 | variant="segmented" | ||
| 116 | value={[metric]} | ||
| 117 | onValueChange={(next) => { | ||
| 118 | const value = next[0]; | ||
| 119 | if (value === "cost" || value === "tokens") setMetric(value); | ||
| 120 | }} | ||
| 121 | > | ||
| 122 | {(["cost", "tokens"] as const).map((option) => ( | ||
| 123 | <Toggle key={option} value={option}> | ||
| 124 | {option === "cost" ? "Cost" : "Tokens"} | ||
| 125 | </Toggle> | ||
| 126 | ))} | ||
| 127 | </ToggleGroup> | ||
| 128 | <ToggleGroup | ||
| 129 | aria-label="Usage period" | ||
| 130 | variant="segmented" | ||
| 131 | value={[String(windowDays)]} | ||
| 132 | onValueChange={(next) => { | ||
| 133 | const value = next[0]; | ||
| 134 | if (value) selectWindow(Number(value)); | ||
| 135 | }} | ||
| 136 | > | ||
| 137 | {WINDOW_OPTIONS.map((option) => ( | ||
| 138 | <Toggle key={option.days} value={String(option.days)}> | ||
| 139 | {option.label} | ||
| 140 | </Toggle> | ||
| 141 | ))} | ||
| 142 | </ToggleGroup> | ||
| 143 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | ||
| 144 | <RefreshCwIcon className="size-3.5" /> | ||
| 145 | </Button> | ||
| 146 | </div> | ||
| 147 | <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden"> | ||
| 148 | <Select | ||
| 149 | value={metric} | ||
| 150 | onValueChange={(value) => { | ||
| 151 | if (value === "cost" || value === "tokens") setMetric(value); | ||
| 152 | }} | ||
| 153 | > | ||
| 154 | <SelectTrigger | ||
| 155 | aria-label="Usage metric" | ||
| 156 | size="compact" | ||
| 157 | variant="ghost" | ||
| 158 | className="w-auto min-w-0" | ||
| 159 | > | ||
| 160 | <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue> | ||
| 161 | </SelectTrigger> | ||
| 162 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 163 | <SelectItem value="cost">Cost</SelectItem> | ||
| 164 | <SelectItem value="tokens">Tokens</SelectItem> | ||
| 165 | </SelectPopup> | ||
| 166 | </Select> | ||
| 167 | <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}> | ||
| 168 | <SelectTrigger | ||
| 169 | aria-label="Usage period" | ||
| 170 | size="compact" | ||
| 171 | variant="ghost" | ||
| 172 | className="w-auto min-w-0" | ||
| 173 | > | ||
| 174 | <SelectValue> | ||
| 175 | {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label} | ||
| 176 | </SelectValue> | ||
| 177 | </SelectTrigger> | ||
| 178 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 179 | {WINDOW_OPTIONS.map((option) => ( | ||
| 180 | <SelectItem key={option.days} value={String(option.days)}> | ||
| 181 | {option.label} | ||
| 182 | </SelectItem> | ||
| 183 | ))} | ||
| 184 | </SelectPopup> | ||
| 185 | </Select> | ||
| 186 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | ||
| 187 | <RefreshCwIcon className="size-3.5" /> | ||
| 188 | </Button> | ||
| 189 | </div> | ||
| 190 | </div> | ||
| 191 | ); | ||
| 105 | 192 | ||
| 106 | return ( | 193 | return ( |
| 107 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 194 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 108 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 195 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 109 | {!isElectron && ( | 196 | <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader> |
| 110 | <header | ||
| 111 | className={cn( | ||
| 112 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", | ||
| 113 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 114 | )} | ||
| 115 | > | ||
| 116 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | ||
| 117 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | ||
| 118 | </WorkspaceBreadcrumb> | ||
| 119 | </header> | ||
| 120 | )} | ||
| 121 | |||
| 122 | {isElectron && ( | ||
| 123 | <div | ||
| 124 | className={cn( | ||
| 125 | "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | ||
| 126 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | ||
| 127 | )} | ||
| 128 | > | ||
| 129 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb"> | ||
| 130 | <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem> | ||
| 131 | </WorkspaceBreadcrumb> | ||
| 132 | </div> | ||
| 133 | )} | ||
| 134 | 197 | ||
| 135 | <ScrollArea className="min-h-0 flex-1"> | 198 | <ScrollArea className="min-h-0 flex-1"> |
| 136 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6"> | 199 | <WorkspacePageContainer width="wide"> |
| 137 | <div className="flex flex-wrap items-center justify-between gap-4"> | ||
| 138 | <p className="text-sm text-muted-foreground"> | ||
| 139 | {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined | ||
| 140 | ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` | ||
| 141 | : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} | ||
| 142 | </p> | ||
| 143 | <div className="flex items-center gap-2"> | ||
| 144 | <div className="flex rounded-md border border-border"> | ||
| 145 | {WINDOW_OPTIONS.map((option) => ( | ||
| 146 | <button | ||
| 147 | key={option.days} | ||
| 148 | type="button" | ||
| 149 | aria-pressed={option.days === windowDays} | ||
| 150 | onClick={() => selectWindow(option.days)} | ||
| 151 | className={cn( | ||
| 152 | "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 153 | option.days === windowDays | ||
| 154 | ? "bg-muted text-foreground" | ||
| 155 | : "text-muted-foreground hover:text-foreground", | ||
| 156 | )} | ||
| 157 | > | ||
| 158 | {option.label} | ||
| 159 | </button> | ||
| 160 | ))} | ||
| 161 | </div> | ||
| 162 | <Button | ||
| 163 | size="icon" | ||
| 164 | variant="outline" | ||
| 165 | onClick={refreshWindow} | ||
| 166 | aria-label="Refresh usage" | ||
| 167 | > | ||
| 168 | <RefreshCwIcon className="size-3.5" /> | ||
| 169 | </Button> | ||
| 170 | </div> | ||
| 171 | </div> | ||
| 172 | |||
| 173 | {settling ? ( | 200 | {settling ? ( |
| 174 | <> | 201 | <> |
| 175 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} | 202 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 176 | <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} /> | 203 | <UsageSkeleton /> |
| 177 | </> | 204 | </> |
| 178 | ) : ( | 205 | ) : ( |
| 179 | <> | 206 | <> |
| ... | @@ -183,88 +210,71 @@ export function UsagePage() { | ... | @@ -183,88 +210,71 @@ export function UsagePage() { |
| 183 | staleEnvironments={merged.staleEnvironments} | 210 | staleEnvironments={merged.staleEnvironments} |
| 184 | /> | 211 | /> |
| 185 | 212 | ||
| 186 | {/* Cost first: the financial answer, then the provider split. */} | 213 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> |
| 187 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | 214 | <div className="flex min-w-0 flex-col gap-5"> |
| 188 | {/* The summary follows the chart toggle, so the headline and the | ||
| 189 | series are always reading the same units. */} | ||
| 190 | <div className="flex flex-col gap-5"> | ||
| 191 | <div className="flex flex-col gap-1"> | 215 | <div className="flex flex-col gap-1"> |
| 192 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | ||
| 193 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | ||
| 194 | </span> | ||
| 195 | <span className="text-4xl font-semibold text-foreground tabular-nums"> | 216 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 196 | {metric === "cost" | 217 | {metric === "cost" |
| 197 | ? `${formatUsd(merged.costUsd)}*` | 218 | ? formatUsd(merged.costUsd) |
| 198 | : formatTokens(merged.totalTokens)} | 219 | : formatTokens(merged.totalTokens)} |
| 199 | </span> | 220 | </span> |
| 200 | <span className="text-xs text-muted-foreground"> | 221 | <span className="text-xs text-muted-foreground"> |
| 201 | {metric === "cost" | 222 | {metric === "cost" |
| 202 | ? "* if billed at full API rate" | 223 | ? `${formatCount(merged.sessions)} sessions · API estimate` |
| 203 | : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} | 224 | : `${formatCount(merged.sessions)} sessions`} |
| 204 | </span> | 225 | </span> |
| 205 | </div> | 226 | </div> |
| 206 | 227 | ||
| 207 | {orderedProviders.map((provider) => { | 228 | {PROVIDER_ORDER.map((provider) => { |
| 208 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; | 229 | const totals = merged.providers.find((entry) => entry.provider === provider); |
| 230 | const share = | ||
| 231 | metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); | ||
| 232 | const providerSessions = totals?.sessions ?? 0; | ||
| 233 | const sessionLabel = `${formatCount(providerSessions)} ${ | ||
| 234 | providerSessions === 1 ? "session" : "sessions" | ||
| 235 | }`; | ||
| 209 | return ( | 236 | return ( |
| 210 | <div key={provider.provider} className="flex flex-col gap-1.5"> | 237 | <div key={provider} className="flex flex-col gap-1"> |
| 211 | <div className="flex items-baseline justify-between"> | 238 | <div className="flex items-baseline justify-between gap-4"> |
| 212 | <span className="flex items-center gap-2 text-sm text-foreground"> | 239 | <span className="flex min-w-0 items-center gap-2 text-sm text-foreground"> |
| 213 | <ProviderMark provider={provider.provider} className="size-4" /> | 240 | <span |
| 214 | {PROVIDER_PRESENTATION[provider.provider].label} | 241 | aria-hidden |
| 242 | className="size-2 shrink-0 rounded-full" | ||
| 243 | style={{ | ||
| 244 | backgroundColor: PROVIDER_PRESENTATION[provider].color, | ||
| 245 | }} | ||
| 246 | /> | ||
| 247 | <ProviderMark provider={provider} className="size-4" /> | ||
| 248 | <span className="flex min-w-0 items-baseline gap-1.5"> | ||
| 249 | <span className="truncate"> | ||
| 250 | {PROVIDER_PRESENTATION[provider].label} | ||
| 251 | </span> | ||
| 252 | <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground tabular-nums"> | ||
| 253 | {sessionLabel} | ||
| 254 | </span> | ||
| 255 | </span> | ||
| 215 | </span> | 256 | </span> |
| 216 | <span className="text-sm text-foreground tabular-nums"> | 257 | <span className="shrink-0 text-sm font-medium text-foreground tabular-nums"> |
| 217 | {metric === "cost" | 258 | {metric === "cost" |
| 218 | ? formatUsd(provider.costUsd) | 259 | ? formatUsd(totals?.costUsd ?? 0) |
| 219 | : formatTokens(provider.totalTokens)} | 260 | : formatTokens(totals?.totalTokens ?? 0)} |
| 220 | </span> | 261 | </span> |
| 221 | </div> | 262 | </div> |
| 222 | <div className="h-1 w-full overflow-hidden rounded-full bg-muted"> | ||
| 223 | <div | ||
| 224 | className="h-full" | ||
| 225 | style={{ | ||
| 226 | width: `${(share * 100).toFixed(1)}%`, | ||
| 227 | backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, | ||
| 228 | }} | ||
| 229 | /> | ||
| 230 | </div> | ||
| 231 | <span className="text-xs text-muted-foreground"> | 263 | <span className="text-xs text-muted-foreground"> |
| 232 | {metric === "cost" | 264 | {metric === "cost" |
| 233 | ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` | 265 | ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` |
| 234 | : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} | 266 | : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} |
| 235 | </span> | 267 | </span> |
| 236 | </div> | 268 | </div> |
| 237 | ); | 269 | ); |
| 238 | })} | 270 | })} |
| 239 | </div> | 271 | </div> |
| 240 | 272 | ||
| 241 | <div className="flex flex-col gap-3"> | 273 | <div className="flex min-w-0 flex-col gap-3"> |
| 242 | <div className="flex flex-wrap items-center justify-between gap-3"> | 274 | <h2 className="text-sm font-medium text-foreground"> |
| 243 | <h2 className="text-sm font-medium text-foreground"> | 275 | {isPast24Hours ? "Hourly" : "Daily"}{" "} |
| 244 | {isPast24Hours ? "Hourly" : "Daily"}{" "} | 276 | {metric === "tokens" ? "processed tokens" : "cost"} |
| 245 | {metric === "tokens" ? "processed tokens" : "cost"} | 277 | </h2> |
| 246 | </h2> | ||
| 247 | <div className="flex items-center gap-4"> | ||
| 248 | <div className="flex overflow-hidden rounded-md border border-border"> | ||
| 249 | {(["cost", "tokens"] as const).map((option) => ( | ||
| 250 | <button | ||
| 251 | key={option} | ||
| 252 | type="button" | ||
| 253 | onClick={() => setMetric(option)} | ||
| 254 | className={cn( | ||
| 255 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | ||
| 256 | option === metric | ||
| 257 | ? "bg-muted text-foreground" | ||
| 258 | : "text-muted-foreground hover:text-foreground", | ||
| 259 | )} | ||
| 260 | > | ||
| 261 | {option} | ||
| 262 | </button> | ||
| 263 | ))} | ||
| 264 | </div> | ||
| 265 | <UsageChartLegend /> | ||
| 266 | </div> | ||
| 267 | </div> | ||
| 268 | <UsageProviderChart | 278 | <UsageProviderChart |
| 269 | days={days} | 279 | days={days} |
| 270 | daily={merged.daily} | 280 | daily={merged.daily} |
| ... | @@ -278,63 +288,46 @@ export function UsagePage() { | ... | @@ -278,63 +288,46 @@ export function UsagePage() { |
| 278 | </div> | 288 | </div> |
| 279 | </section> | 289 | </section> |
| 280 | 290 | ||
| 281 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | 291 | <section className="flex flex-col gap-2"> |
| 282 | <Metric | 292 | <h2 className="text-sm font-medium text-foreground">Totals</h2> |
| 283 | label="Processed tokens" | 293 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> |
| 284 | value={formatTokens(merged.totalTokens)} | 294 | <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} /> |
| 285 | detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`} | 295 | <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} /> |
| 286 | /> | 296 | <Metric |
| 287 | <Metric | 297 | label="Uncached input" |
| 288 | label="Cached input" | 298 | value={formatTokens(merged.uncachedInputTokens)} |
| 289 | value={formatTokens(merged.cachedInputTokens)} | 299 | /> |
| 290 | detail={`${formatPercent(cachedShare)} of observed input`} | 300 | <Metric label="Output" value={formatTokens(merged.outputTokens)} /> |
| 291 | /> | 301 | <Metric |
| 292 | <Metric | 302 | label="Cache savings" |
| 293 | label="Uncached input" | 303 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} |
| 294 | value={formatTokens(merged.uncachedInputTokens)} | 304 | /> |
| 295 | detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} | 305 | </div> |
| 296 | /> | ||
| 297 | <Metric | ||
| 298 | label="Output" | ||
| 299 | value={formatTokens(merged.outputTokens)} | ||
| 300 | detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} | ||
| 301 | /> | ||
| 302 | <Metric | ||
| 303 | label="Cache savings" | ||
| 304 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} | ||
| 305 | detail={ | ||
| 306 | merged.costUsd > 0 | ||
| 307 | ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` | ||
| 308 | : "vs full input rates" | ||
| 309 | } | ||
| 310 | /> | ||
| 311 | </section> | 306 | </section> |
| 312 | 307 | ||
| 313 | <section className="flex flex-col gap-3"> | 308 | <section className="flex flex-col gap-3"> |
| 314 | <div className="flex items-center justify-between gap-3"> | 309 | <div className="flex items-center justify-between gap-3"> |
| 315 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> | 310 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 316 | <div className="flex overflow-hidden rounded-md border border-border"> | 311 | <ToggleGroup |
| 312 | aria-label="Usage breakdown" | ||
| 313 | variant="segmented" | ||
| 314 | value={[breakdown]} | ||
| 315 | onValueChange={(next) => { | ||
| 316 | const value = next[0]; | ||
| 317 | if (value === "model" || value === "time") setBreakdown(value); | ||
| 318 | }} | ||
| 319 | > | ||
| 317 | {( | 320 | {( |
| 318 | [ | 321 | [ |
| 319 | { value: "model", label: "model" }, | 322 | { value: "model", label: "Model" }, |
| 320 | { value: "time", label: isPast24Hours ? "hour" : "day" }, | 323 | { value: "time", label: isPast24Hours ? "Hour" : "Day" }, |
| 321 | ] as const | 324 | ] as const |
| 322 | ).map((option) => ( | 325 | ).map((option) => ( |
| 323 | <button | 326 | <Toggle key={option.value} value={option.value}> |
| 324 | key={option.value} | ||
| 325 | type="button" | ||
| 326 | onClick={() => setBreakdown(option.value)} | ||
| 327 | className={cn( | ||
| 328 | "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", | ||
| 329 | option.value === breakdown | ||
| 330 | ? "bg-muted text-foreground" | ||
| 331 | : "text-muted-foreground hover:text-foreground", | ||
| 332 | )} | ||
| 333 | > | ||
| 334 | {option.label} | 327 | {option.label} |
| 335 | </button> | 328 | </Toggle> |
| 336 | ))} | 329 | ))} |
| 337 | </div> | 330 | </ToggleGroup> |
| 338 | </div> | 331 | </div> |
| 339 | 332 | ||
| 340 | {breakdown === "model" ? ( | 333 | {breakdown === "model" ? ( |
| ... | @@ -358,7 +351,7 @@ export function UsagePage() { | ... | @@ -358,7 +351,7 @@ export function UsagePage() { |
| 358 | merged.models.map((model) => ( | 351 | merged.models.map((model) => ( |
| 359 | <tr | 352 | <tr |
| 360 | key={`${model.provider}:${model.model}`} | 353 | key={`${model.provider}:${model.model}`} |
| 361 | className="border-b border-border/50" | 354 | className="border-b border-border/50 transition-colors hover:bg-muted/50" |
| 362 | > | 355 | > |
| 363 | <td className="py-2 text-foreground"> | 356 | <td className="py-2 text-foreground"> |
| 364 | <span className="flex items-center gap-2"> | 357 | <span className="flex items-center gap-2"> |
| ... | @@ -405,7 +398,7 @@ export function UsagePage() { | ... | @@ -405,7 +398,7 @@ export function UsagePage() { |
| 405 | breakdownPeriods.map((period) => ( | 398 | breakdownPeriods.map((period) => ( |
| 406 | <tr | 399 | <tr |
| 407 | key={"hourStart" in period ? period.hourStart : period.day} | 400 | key={"hourStart" in period ? period.hourStart : period.day} |
| 408 | className="border-b border-border/50" | 401 | className="border-b border-border/50 transition-colors hover:bg-muted/50" |
| 409 | > | 402 | > |
| 410 | <td className="py-2 text-foreground"> | 403 | <td className="py-2 text-foreground"> |
| 411 | {"hourStart" in period | 404 | {"hourStart" in period |
| ... | @@ -435,7 +428,7 @@ export function UsagePage() { | ... | @@ -435,7 +428,7 @@ export function UsagePage() { |
| 435 | </section> | 428 | </section> |
| 436 | </> | 429 | </> |
| 437 | )} | 430 | )} |
| 438 | </div> | 431 | </WorkspacePageContainer> |
| 439 | </ScrollArea> | 432 | </ScrollArea> |
| 440 | </div> | 433 | </div> |
| 441 | </SidebarInset> | 434 | </SidebarInset> |
| ... | @@ -454,20 +447,11 @@ function ProviderMark({ | ... | @@ -454,20 +447,11 @@ function ProviderMark({ |
| 454 | return <Mark className={cn("shrink-0", className)} aria-hidden />; | 447 | return <Mark className={cn("shrink-0", className)} aria-hidden />; |
| 455 | } | 448 | } |
| 456 | 449 | ||
| 457 | function Metric({ | 450 | function Metric({ label, value }: { readonly label: string; readonly value: string }) { |
| 458 | label, | ||
| 459 | value, | ||
| 460 | detail, | ||
| 461 | }: { | ||
| 462 | readonly label: string; | ||
| 463 | readonly value: string; | ||
| 464 | readonly detail: string; | ||
| 465 | }) { | ||
| 466 | return ( | 451 | return ( |
| 467 | <div className="flex flex-col gap-0.5 bg-background px-4 py-3"> | 452 | <div className="flex min-w-0 flex-col gap-0.5"> |
| 468 | <span className="text-xs text-muted-foreground">{label}</span> | 453 | <span className="text-xs text-muted-foreground">{label}</span> |
| 469 | <span className="text-lg text-foreground tabular-nums">{value}</span> | 454 | <span className="text-base font-medium text-foreground tabular-nums">{value}</span> |
| 470 | <span className="text-xs text-muted-foreground">{detail}</span> | ||
| 471 | </div> | 455 | </div> |
| 472 | ); | 456 | ); |
| 473 | } | 457 | } |
| ... | @@ -571,70 +555,59 @@ function UsageDeviceStrip({ | ... | @@ -571,70 +555,59 @@ function UsageDeviceStrip({ |
| 571 | ); | 555 | ); |
| 572 | } | 556 | } |
| 573 | 557 | ||
| 574 | /** Deterministic bar heights (each unique: they double as keys). */ | ||
| 575 | const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; | ||
| 576 | |||
| 577 | /** | 558 | /** |
| 578 | * Static stand-in with the loaded page's shape: headline, provider split, | 559 | * Static stand-in with the loaded page's shape. No shimmer; blocks fill in |
| 579 | * chart and metrics strip. No shimmer; blocks fill in exactly once when the | 560 | * exactly once when the last device answers. |
| 580 | * last device answers. | ||
| 581 | */ | 561 | */ |
| 582 | function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { | 562 | function UsageSkeleton() { |
| 583 | return ( | 563 | return ( |
| 584 | <> | 564 | <> |
| 585 | <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | 565 | <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> |
| 586 | <div className="flex flex-col gap-5"> | 566 | <div className="flex flex-col gap-5"> |
| 587 | <div className="flex flex-col gap-1"> | 567 | <div className="flex flex-col gap-1"> |
| 588 | <span className="text-xs tracking-wide text-muted-foreground uppercase"> | 568 | <div className="h-10 w-36 rounded-sm bg-muted" /> |
| 589 | Raw token cost | 569 | <div className="h-4 w-32 rounded-sm bg-muted" /> |
| 590 | </span> | ||
| 591 | <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" /> | ||
| 592 | <div className="h-3 w-28 rounded-sm bg-muted" /> | ||
| 593 | </div> | 570 | </div> |
| 594 | |||
| 595 | {PROVIDER_ORDER.map((provider) => ( | 571 | {PROVIDER_ORDER.map((provider) => ( |
| 596 | <div key={provider} className="flex flex-col gap-1.5"> | 572 | <div key={provider} className="flex flex-col gap-1"> |
| 597 | <div className="flex items-center justify-between"> | 573 | <div className="flex min-h-5 items-center justify-between gap-4"> |
| 598 | <span className="flex items-center gap-2 text-sm text-foreground"> | 574 | <span className="flex items-center gap-2"> |
| 575 | <span | ||
| 576 | aria-hidden | ||
| 577 | className="size-2 shrink-0 rounded-full" | ||
| 578 | style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }} | ||
| 579 | /> | ||
| 599 | <ProviderMark provider={provider} className="size-4" /> | 580 | <ProviderMark provider={provider} className="size-4" /> |
| 600 | {PROVIDER_PRESENTATION[provider].label} | 581 | <div className="h-3.5 w-20 rounded-sm bg-muted" /> |
| 601 | </span> | 582 | </span> |
| 602 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> | 583 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 603 | </div> | 584 | </div> |
| 604 | <div className="h-1 w-full rounded-full bg-muted" /> | 585 | <div className="h-4 w-36 rounded-sm bg-muted" /> |
| 605 | <div className="h-3 w-36 rounded-sm bg-muted" /> | ||
| 606 | </div> | 586 | </div> |
| 607 | ))} | 587 | ))} |
| 608 | </div> | 588 | </div> |
| 609 | 589 | ||
| 610 | <div className="flex flex-col gap-3"> | 590 | <div className="flex flex-col gap-3"> |
| 611 | <h2 className="py-1 text-sm font-medium text-foreground"> | 591 | <div className="h-5 w-24 rounded-sm bg-muted" /> |
| 612 | {resolution === "hour" ? "Hourly" : "Daily"} cost | 592 | <div className="flex flex-col gap-1"> |
| 613 | </h2> | 593 | <div className="ml-16 h-56 rounded-sm bg-muted/35" /> |
| 614 | {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a | 594 | <div className="ml-16 h-3 rounded-sm bg-muted/35" /> |
| 615 | relayout when the real chart swaps in. */} | ||
| 616 | <div className="flex h-56 items-end gap-1 pl-16"> | ||
| 617 | {SKELETON_BAR_HEIGHTS.map((height) => ( | ||
| 618 | <div | ||
| 619 | key={height} | ||
| 620 | className="flex-1 rounded-sm bg-muted" | ||
| 621 | style={{ height: `${height}%` }} | ||
| 622 | /> | ||
| 623 | ))} | ||
| 624 | </div> | 595 | </div> |
| 625 | </div> | 596 | </div> |
| 626 | </section> | 597 | </section> |
| 627 | 598 | ||
| 628 | <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | 599 | <section className="flex flex-col gap-2"> |
| 629 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | 600 | <h2 className="text-sm font-medium text-foreground">Totals</h2> |
| 630 | (label) => ( | 601 | <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> |
| 631 | <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3"> | 602 | {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( |
| 632 | <span className="text-xs text-muted-foreground">{label}</span> | 603 | (label) => ( |
| 633 | <div className="my-1 h-5 w-16 rounded-sm bg-muted" /> | 604 | <div key={label} className="flex flex-col gap-0.5"> |
| 634 | <div className="h-3 w-24 rounded-sm bg-muted" /> | 605 | <span className="text-xs text-muted-foreground">{label}</span> |
| 635 | </div> | 606 | <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" /> |
| 636 | ), | 607 | </div> |
| 637 | )} | 608 | ), |
| 609 | )} | ||
| 610 | </div> | ||
| 638 | </section> | 611 | </section> |
| 639 | </> | 612 | </> |
| 640 | ); | 613 | ); |
apps/web/src/components/usage/UsageProviderChart.tsx+88-64| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 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, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 3 | 3 | ||
| 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; | 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | import { | 5 | import { |
| ... | @@ -68,13 +68,7 @@ function buildPeriodColumns( | ... | @@ -68,13 +68,7 @@ function buildPeriodColumns( |
| 68 | }); | 68 | }); |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | /** | 71 | /** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ |
| 72 | * Monotone cubic tangents (Fritsch-Carlson). | ||
| 73 | * | ||
| 74 | * Plain cubic smoothing overshoots on spiky daily data and would dip the area | ||
| 75 | * below zero between points, which reads as negative spend. This variant is | ||
| 76 | * shape-preserving, so a smoothed series never leaves the range of its samples. | ||
| 77 | */ | ||
| 78 | function monotoneTangents(points: readonly Point[]): readonly number[] { | 72 | function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 79 | const count = points.length; | 73 | const count = points.length; |
| 80 | if (count < 2) return [0]; | 74 | if (count < 2) return [0]; |
| ... | @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { | ... | @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 115 | return tangents; | 109 | return tangents; |
| 116 | } | 110 | } |
| 117 | 111 | ||
| 118 | /** One cubic segment of a smoothed boundary. */ | ||
| 119 | interface CurveSegment { | 112 | interface CurveSegment { |
| 120 | readonly from: Point; | 113 | readonly from: Point; |
| 121 | readonly c1: Point; | 114 | readonly c1: Point; |
| ... | @@ -123,7 +116,6 @@ interface CurveSegment { | ... | @@ -123,7 +116,6 @@ interface CurveSegment { |
| 123 | readonly to: Point; | 116 | readonly to: Point; |
| 124 | } | 117 | } |
| 125 | 118 | ||
| 126 | /** Smoothed polyline through `points`, as explicit cubic control points. */ | ||
| 127 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { | 119 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 128 | if (points.length < 2) return []; | 120 | if (points.length < 2) return []; |
| 129 | const tangents = monotoneTangents(points); | 121 | const tangents = monotoneTangents(points); |
| ... | @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { | ... | @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 144 | return segments; | 136 | return segments; |
| 145 | } | 137 | } |
| 146 | 138 | ||
| 147 | function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { | 139 | function curvePath(segments: readonly CurveSegment[]): string { |
| 148 | const first = segments[0]; | 140 | const first = segments[0]; |
| 149 | if (first === undefined) return ""; | 141 | if (first === undefined) return ""; |
| 150 | let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; | 142 | let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; |
| 151 | for (const segment of segments) { | 143 | for (const segment of segments) { |
| 152 | path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; | 144 | path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; |
| 153 | } | 145 | } |
| ... | @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re | ... | @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re |
| 179 | /** | 171 | /** |
| 180 | * Turns the merged daily totals into one column per day. | 172 | * Turns the merged daily totals into one column per day. |
| 181 | * | 173 | * |
| 182 | * Values are absolute, not cumulative: the series are layered from a shared | 174 | * Values are absolute, not cumulative: each provider is drawn from the same |
| 183 | * zero baseline rather than stacked. A stacked chart puts whichever provider is | 175 | * zero baseline so the chart never implies that one provider is always larger. |
| 184 | * drawn last permanently above the other, which reads as "that one is bigger" | ||
| 185 | * even on days where it is not. | ||
| 186 | * | 176 | * |
| 187 | * The chart paths and the hover readout both consume this, so the number under | 177 | * The chart paths and the hover readout both consume this, so the number under |
| 188 | * the cursor is by construction the number that was plotted rather than a | 178 | * the cursor is by construction the number that was plotted rather than a |
| ... | @@ -216,42 +206,44 @@ export function UsageProviderChart({ | ... | @@ -216,42 +206,44 @@ export function UsageProviderChart({ |
| 216 | ); | 206 | ); |
| 217 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); | 207 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); |
| 218 | const plotRef = useRef<HTMLDivElement | null>(null); | 208 | const plotRef = useRef<HTMLDivElement | null>(null); |
| 209 | const tooltipRef = useRef<HTMLDivElement | null>(null); | ||
| 210 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); | ||
| 219 | 211 | ||
| 220 | const { paths, ticks, stepX, toY, series } = useMemo(() => { | 212 | const { paths, series, stepX, ticks, toY } = useMemo(() => { |
| 221 | if (periods.length === 0) { | 213 | if (periods.length === 0) { |
| 222 | return { | 214 | return { |
| 223 | paths: [], | 215 | paths: [], |
| 224 | ticks: [0] as readonly number[], | 216 | series: [] as readonly DayColumn[], |
| 225 | stepX: 0, | 217 | stepX: 0, |
| 218 | ticks: [0] as readonly number[], | ||
| 226 | toY: () => VIEW_HEIGHT, | 219 | toY: () => VIEW_HEIGHT, |
| 227 | series: [] as readonly DayColumn[], | ||
| 228 | }; | 220 | }; |
| 229 | } | 221 | } |
| 230 | 222 | ||
| 231 | const columns = buildPeriodColumns(periods, byPeriod, metric); | 223 | const columns = buildPeriodColumns(periods, byPeriod, metric); |
| 232 | 224 | // The scale tops out at the largest single provider-period, not the sum: | |
| 233 | // The scale tops out at the largest single provider-day, not the largest | 225 | // layered series each measure from zero, so a combined peak would leave |
| 234 | // sum: layered series each measure from zero, so a combined peak would | 226 | // the plot permanently half empty. |
| 235 | // leave the plot permanently half empty. | ||
| 236 | const peak = columns.reduce( | 227 | const peak = columns.reduce( |
| 237 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), | 228 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), |
| 238 | 0, | 229 | 0, |
| 239 | ); | 230 | ); |
| 240 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); | 231 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 241 | const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); | 232 | const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); |
| 242 | // Reserve a sliver above the top gridline so the series stroke, which is | 233 | // Leave room above the top gridline so the constant-width stroke is not |
| 243 | // drawn at constant screen width, is not shaved off at a peak. | 234 | // clipped when a series reaches the peak. |
| 244 | const toY = (value: number) => | 235 | const toY = (value: number) => |
| 245 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); | 236 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 246 | 237 | ||
| 247 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { | 238 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 248 | const curve = smoothCurve( | 239 | const line = curvePath( |
| 249 | columns.map((column, dayIndex) => ({ | 240 | smoothCurve( |
| 250 | x: dayIndex * step, | 241 | columns.map((column, periodIndex) => ({ |
| 251 | y: toY(column.bands[providerIndex]?.value ?? 0), | 242 | x: periodIndex * step, |
| 252 | })), | 243 | y: toY(column.bands[providerIndex]?.value ?? 0), |
| 244 | })), | ||
| 245 | ), | ||
| 253 | ); | 246 | ); |
| 254 | const line = curvePath(curve, "M"); | ||
| 255 | return { | 247 | return { |
| 256 | provider, | 248 | provider, |
| 257 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), | 249 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| ... | @@ -260,30 +252,76 @@ export function UsageProviderChart({ | ... | @@ -260,30 +252,76 @@ export function UsageProviderChart({ |
| 260 | }; | 252 | }; |
| 261 | }); | 253 | }); |
| 262 | 254 | ||
| 263 | // Paint the heavier series first so the lighter one is never buried under | 255 | // Paint the heavier series first so the lighter one is not buried. |
| 264 | // it. The fills are faint enough that the order barely shows, but the | 256 | return { |
| 265 | // strokes are drawn in a second pass regardless, so neither can be hidden. | 257 | paths: built.toSorted((a, b) => b.total - a.total), |
| 266 | const ordered = [...built].sort((a, b) => b.total - a.total); | 258 | series: columns, |
| 267 | 259 | stepX: step, | |
| 268 | return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; | 260 | ticks: tickValues, |
| 261 | toY, | ||
| 262 | }; | ||
| 269 | }, [byPeriod, metric, periods]); | 263 | }, [byPeriod, metric, periods]); |
| 270 | 264 | ||
| 271 | const format = metric === "tokens" ? formatTokens : formatUsd; | 265 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| 272 | 266 | ||
| 267 | const positionTooltip = useCallback(() => { | ||
| 268 | const plot = plotRef.current; | ||
| 269 | const tooltip = tooltipRef.current; | ||
| 270 | const hoverPosition = hoverPositionRef.current; | ||
| 271 | if (plot === null || tooltip === null || hoverPosition === null) return; | ||
| 272 | |||
| 273 | const gap = 12; | ||
| 274 | const tooltipWidth = tooltip.offsetWidth; | ||
| 275 | const tooltipHeight = tooltip.offsetHeight; | ||
| 276 | const plotWidth = plot.clientWidth; | ||
| 277 | const plotHeight = plot.clientHeight; | ||
| 278 | const preferredLeft = | ||
| 279 | hoverPosition.x + gap + tooltipWidth <= plotWidth | ||
| 280 | ? hoverPosition.x + gap | ||
| 281 | : hoverPosition.x - gap - tooltipWidth; | ||
| 282 | const preferredTop = | ||
| 283 | hoverPosition.y + gap + tooltipHeight <= plotHeight | ||
| 284 | ? hoverPosition.y + gap | ||
| 285 | : hoverPosition.y - gap - tooltipHeight; | ||
| 286 | const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); | ||
| 287 | const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); | ||
| 288 | plot.style.setProperty("--usage-tooltip-left", `${left}px`); | ||
| 289 | plot.style.setProperty("--usage-tooltip-top", `${top}px`); | ||
| 290 | }, []); | ||
| 291 | |||
| 292 | useLayoutEffect(() => { | ||
| 293 | if (hoverIndex === null) return; | ||
| 294 | positionTooltip(); | ||
| 295 | |||
| 296 | const plot = plotRef.current; | ||
| 297 | const tooltip = tooltipRef.current; | ||
| 298 | if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return; | ||
| 299 | |||
| 300 | const observer = new ResizeObserver(positionTooltip); | ||
| 301 | observer.observe(plot); | ||
| 302 | observer.observe(tooltip); | ||
| 303 | return () => observer.disconnect(); | ||
| 304 | }, [hoverIndex, positionTooltip]); | ||
| 305 | |||
| 273 | const handleMove = useCallback( | 306 | const handleMove = useCallback( |
| 274 | (event: React.MouseEvent<HTMLDivElement>) => { | 307 | (event: React.MouseEvent<HTMLDivElement>) => { |
| 275 | const bounds = plotRef.current?.getBoundingClientRect(); | 308 | const plot = plotRef.current; |
| 276 | if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; | 309 | if (plot === null || periods.length === 0) return; |
| 277 | const fraction = (event.clientX - bounds.left) / bounds.width; | 310 | const bounds = plot.getBoundingClientRect(); |
| 311 | if (bounds.width === 0) return; | ||
| 312 | const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); | ||
| 313 | const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); | ||
| 314 | const fraction = localX / bounds.width; | ||
| 278 | const index = Math.round(fraction * (periods.length - 1)); | 315 | const index = Math.round(fraction * (periods.length - 1)); |
| 316 | hoverPositionRef.current = { x: localX, y: localY }; | ||
| 317 | positionTooltip(); | ||
| 279 | setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); | 318 | setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); |
| 280 | }, | 319 | }, |
| 281 | [periods.length], | 320 | [periods.length, positionTooltip], |
| 282 | ); | 321 | ); |
| 283 | 322 | ||
| 284 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; | 323 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; |
| 285 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; | 324 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; |
| 286 | const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; | ||
| 287 | const formatPeriod = (period: string) => | 325 | const formatPeriod = (period: string) => |
| 288 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); | 326 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); |
| 289 | const formatTooltipPeriod = (period: string) => | 327 | const formatTooltipPeriod = (period: string) => |
| ... | @@ -311,7 +349,10 @@ export function UsageProviderChart({ | ... | @@ -311,7 +349,10 @@ export function UsageProviderChart({ |
| 311 | ref={plotRef} | 349 | ref={plotRef} |
| 312 | className="relative h-56 flex-1" | 350 | className="relative h-56 flex-1" |
| 313 | onMouseMove={handleMove} | 351 | onMouseMove={handleMove} |
| 314 | onMouseLeave={() => setHoverIndex(null)} | 352 | onMouseLeave={() => { |
| 353 | hoverPositionRef.current = null; | ||
| 354 | setHoverIndex(null); | ||
| 355 | }} | ||
| 315 | > | 356 | > |
| 316 | <svg | 357 | <svg |
| 317 | className="h-full w-full" | 358 | className="h-full w-full" |
| ... | @@ -373,10 +414,11 @@ export function UsageProviderChart({ | ... | @@ -373,10 +414,11 @@ export function UsageProviderChart({ |
| 373 | 414 | ||
| 374 | {hoveredPeriod === undefined ? null : ( | 415 | {hoveredPeriod === undefined ? null : ( |
| 375 | <div | 416 | <div |
| 376 | className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs" | 417 | ref={tooltipRef} |
| 418 | className="surface-glass pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 px-2.5 py-2 text-xs shadow-lg" | ||
| 377 | style={{ | 419 | style={{ |
| 378 | left: `${hoverLeft}%`, | 420 | left: "var(--usage-tooltip-left, 0px)", |
| 379 | transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)", | 421 | top: "var(--usage-tooltip-top, 0px)", |
| 380 | }} | 422 | }} |
| 381 | > | 423 | > |
| 382 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> | 424 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| ... | @@ -423,21 +465,3 @@ export function UsageProviderChart({ | ... | @@ -423,21 +465,3 @@ export function UsageProviderChart({ |
| 423 | </div> | 465 | </div> |
| 424 | ); | 466 | ); |
| 425 | } | 467 | } |
| 426 | |||
| 427 | export function UsageChartLegend() { | ||
| 428 | return ( | ||
| 429 | <div className="flex items-center gap-4"> | ||
| 430 | {PROVIDER_ORDER.map((provider) => { | ||
| 431 | // Brand marks keep monochrome providers identifiable even when their | ||
| 432 | // chart series use distinct colors. | ||
| 433 | const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; | ||
| 434 | return ( | ||
| 435 | <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 436 | <Mark className="size-3.5 shrink-0" aria-hidden /> | ||
| 437 | {label} | ||
| 438 | </span> | ||
| 439 | ); | ||
| 440 | })} | ||
| 441 | </div> | ||
| 442 | ); | ||
| 443 | } |
apps/web/src/components/usage/usageProviders.ts+1-1| ... | @@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = { | ... | @@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = { |
| 26 | }, | 26 | }, |
| 27 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; | 27 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; |
| 28 | 28 | ||
| 29 | /** The chart layers every series from zero, so order only controls how it is read. */ | 29 | /** Stable provider reading order across charts, summaries, tables, and hover rows. */ |
| 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; | 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; |
packages/shared/src/usageMerge.test.ts+31| ... | @@ -138,6 +138,12 @@ describe("mergeUsage", () => { | ... | @@ -138,6 +138,12 @@ describe("mergeUsage", () => { |
| 138 | "claude", | 138 | "claude", |
| 139 | "codex", | 139 | "codex", |
| 140 | ]); | 140 | ]); |
| 141 | expect(merged.sessions).toBe(2); | ||
| 142 | expect( | ||
| 143 | Object.fromEntries( | ||
| 144 | merged.providers.map((provider) => [provider.provider, provider.sessions]), | ||
| 145 | ), | ||
| 146 | ).toEqual({ claude: 1, codex: 1 }); | ||
| 141 | }); | 147 | }); |
| 142 | 148 | ||
| 143 | it("excludes an environment reporting an older contract version", () => { | 149 | it("excludes an environment reporting an older contract version", () => { |
| ... | @@ -248,6 +254,7 @@ describe("mergeUsage", () => { | ... | @@ -248,6 +254,7 @@ describe("mergeUsage", () => { |
| 248 | ); | 254 | ); |
| 249 | 255 | ||
| 250 | expect(merged.sessions).toBe(1); | 256 | expect(merged.sessions).toBe(1); |
| 257 | expect(merged.providers[0]?.sessions).toBe(1); | ||
| 251 | }); | 258 | }); |
| 252 | 259 | ||
| 253 | it("returns empty totals with no environments", () => { | 260 | it("returns empty totals with no environments", () => { |
| ... | @@ -257,6 +264,30 @@ describe("mergeUsage", () => { | ... | @@ -257,6 +264,30 @@ describe("mergeUsage", () => { |
| 257 | expect(merged.hourly).toHaveLength(0); | 264 | expect(merged.hourly).toHaveLength(0); |
| 258 | }); | 265 | }); |
| 259 | 266 | ||
| 267 | it("omits providers with no sessions or usage", () => { | ||
| 268 | const merged = mergeUsage( | ||
| 269 | [ | ||
| 270 | environment( | ||
| 271 | "env-a", | ||
| 272 | summary( | ||
| 273 | [], | ||
| 274 | [ | ||
| 275 | { | ||
| 276 | provider: "claude", | ||
| 277 | hostId: "mac", | ||
| 278 | homePath: "/a/.claude", | ||
| 279 | distinctSessions: 0, | ||
| 280 | }, | ||
| 281 | ], | ||
| 282 | ), | ||
| 283 | ), | ||
| 284 | ], | ||
| 285 | USAGE_CONTRACT_VERSION, | ||
| 286 | ); | ||
| 287 | |||
| 288 | expect(merged.providers).toEqual([]); | ||
| 289 | }); | ||
| 290 | |||
| 260 | it("derives hourly totals without losing the daily rollup", () => { | 291 | it("derives hourly totals without losing the daily rollup", () => { |
| 261 | const merged = mergeUsage( | 292 | const merged = mergeUsage( |
| 262 | [ | 293 | [ |
packages/shared/src/usageMerge.ts+30-11| ... | @@ -25,6 +25,7 @@ export interface ProviderTotals { | ... | @@ -25,6 +25,7 @@ export interface ProviderTotals { |
| 25 | readonly costUsd: number; | 25 | readonly costUsd: number; |
| 26 | readonly totalTokens: number; | 26 | readonly totalTokens: number; |
| 27 | readonly records: number; | 27 | readonly records: number; |
| 28 | readonly sessions: number; | ||
| 28 | readonly costShare: number; | 29 | readonly costShare: number; |
| 29 | readonly tokenShare: number; | 30 | readonly tokenShare: number; |
| 30 | } | 31 | } |
| ... | @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { | ... | @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { |
| 135 | function ownedContribution( | 136 | function ownedContribution( |
| 136 | environment: EnvironmentUsage, | 137 | environment: EnvironmentUsage, |
| 137 | ownerByFingerprint: ReadonlyMap<string, EnvironmentId>, | 138 | ownerByFingerprint: ReadonlyMap<string, EnvironmentId>, |
| 138 | ): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { | 139 | ): { |
| 140 | readonly buckets: readonly UsageBucket[]; | ||
| 141 | readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>; | ||
| 142 | } { | ||
| 139 | const ownedProviders = new Set<UsageProviderKind>(); | 143 | const ownedProviders = new Set<UsageProviderKind>(); |
| 140 | let sessions = 0; | 144 | const sessionsByProvider = new Map<UsageProviderKind, number>(); |
| 141 | for (const source of environment.summary.sources) { | 145 | for (const source of environment.summary.sources) { |
| 142 | if (source.status === "missing") continue; | 146 | if (source.status === "missing") continue; |
| 143 | const key = fingerprintKey(source.fingerprint); | 147 | const key = fingerprintKey(source.fingerprint); |
| 144 | if (ownerByFingerprint.get(key) === environment.environmentId) { | 148 | if (ownerByFingerprint.get(key) === environment.environmentId) { |
| 145 | ownedProviders.add(source.fingerprint.provider); | 149 | const provider = source.fingerprint.provider; |
| 150 | ownedProviders.add(provider); | ||
| 146 | // Distinct within a directory. Summing per-bucket session counts instead | 151 | // Distinct within a directory. Summing per-bucket session counts instead |
| 147 | // would count a session once per day and model it spans. | 152 | // would count a session once per day and model it spans. |
| 148 | sessions += source.distinctSessions; | 153 | sessionsByProvider.set( |
| 154 | provider, | ||
| 155 | (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, | ||
| 156 | ); | ||
| 149 | } | 157 | } |
| 150 | } | 158 | } |
| 151 | return { | 159 | return { |
| 152 | buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), | 160 | buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), |
| 153 | sessions, | 161 | sessionsByProvider, |
| 154 | }; | 162 | }; |
| 155 | } | 163 | } |
| 156 | 164 | ||
| ... | @@ -228,7 +236,7 @@ export function mergeUsage( | ... | @@ -228,7 +236,7 @@ export function mergeUsage( |
| 228 | 236 | ||
| 229 | const providerAccumulator = new Map< | 237 | const providerAccumulator = new Map< |
| 230 | UsageProviderKind, | 238 | UsageProviderKind, |
| 231 | { costUsd: number; totalTokens: number; records: number } | 239 | { costUsd: number; totalTokens: number; records: number; sessions: number } |
| 232 | >(); | 240 | >(); |
| 233 | const modelAccumulator = new Map< | 241 | const modelAccumulator = new Map< |
| 234 | string, | 242 | string, |
| ... | @@ -255,12 +263,21 @@ export function mergeUsage( | ... | @@ -255,12 +263,21 @@ export function mergeUsage( |
| 255 | const contributingEnvironments: EnvironmentId[] = []; | 263 | const contributingEnvironments: EnvironmentId[] = []; |
| 256 | 264 | ||
| 257 | for (const environment of current) { | 265 | for (const environment of current) { |
| 258 | const { buckets, sessions: environmentSessions } = ownedContribution( | 266 | const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); |
| 259 | environment, | ||
| 260 | ownerByFingerprint, | ||
| 261 | ); | ||
| 262 | if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); | 267 | if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); |
| 263 | sessions += environmentSessions; | 268 | |
| 269 | for (const [providerKind, providerSessions] of sessionsByProvider) { | ||
| 270 | sessions += providerSessions; | ||
| 271 | if (providerSessions === 0) continue; | ||
| 272 | const provider = providerAccumulator.get(providerKind) ?? { | ||
| 273 | costUsd: 0, | ||
| 274 | totalTokens: 0, | ||
| 275 | records: 0, | ||
| 276 | sessions: 0, | ||
| 277 | }; | ||
| 278 | provider.sessions += providerSessions; | ||
| 279 | providerAccumulator.set(providerKind, provider); | ||
| 280 | } | ||
| 264 | 281 | ||
| 265 | for (const bucket of buckets) { | 282 | for (const bucket of buckets) { |
| 266 | const tokens = bucketTokens(bucket); | 283 | const tokens = bucketTokens(bucket); |
| ... | @@ -280,6 +297,7 @@ export function mergeUsage( | ... | @@ -280,6 +297,7 @@ export function mergeUsage( |
| 280 | costUsd: 0, | 297 | costUsd: 0, |
| 281 | totalTokens: 0, | 298 | totalTokens: 0, |
| 282 | records: 0, | 299 | records: 0, |
| 300 | sessions: 0, | ||
| 283 | }; | 301 | }; |
| 284 | provider.costUsd += bucket.costUsd; | 302 | provider.costUsd += bucket.costUsd; |
| 285 | provider.totalTokens += tokens; | 303 | provider.totalTokens += tokens; |
| ... | @@ -341,6 +359,7 @@ export function mergeUsage( | ... | @@ -341,6 +359,7 @@ export function mergeUsage( |
| 341 | costUsd: totals.costUsd, | 359 | costUsd: totals.costUsd, |
| 342 | totalTokens: totals.totalTokens, | 360 | totalTokens: totals.totalTokens, |
| 343 | records: totals.records, | 361 | records: totals.records, |
| 362 | sessions: totals.sessions, | ||
| 344 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, | 363 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 345 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, | 364 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, |
| 346 | })) | 365 | })) |