Unified · Split
| @@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({ · 44 unmodified lines | ||
| 45 | 45 | ); |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | −export function WorkspaceBreadcrumbSeparator() { | |
| 48 | +export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) { | |
| 49 | 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 | 52 | </li> |
| 53 | 53 | ); |
| @@ -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 | +} | |
| @@ -12,17 +12,10 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "react"; |
| 13 | 13 | |
| 14 | 14 | import { cn } from "../../lib/utils"; |
| 15 | +import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; | |
| 15 | 16 | import { Button } from "../ui/button"; |
| 16 | 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 | 19 | interface SettingsSearchTargetContextValue { |
| 27 | 20 | readonly targetId: string | null; |
| 28 | 21 | readonly onTargetHandled: () => void; |
| @@ -245,7 +238,7 @@ export function SettingsPageContainer({ · 216 unmodified lines | ||
| 245 | 238 | }: { |
| 246 | 239 | children: ReactNode; |
| 247 | 240 | className?: string; |
| 248 | − width?: SettingsPageWidth; | |
| 241 | + width?: WorkspacePageWidth; | |
| 249 | 242 | }) { |
| 250 | 243 | const navigate = useNavigate(); |
| 251 | 244 | const hash = useLocation({ select: (location) => location.hash }); |
| @@ -260,15 +253,9 @@ export function SettingsPageContainer({ · 8 unmodified lines | ||
| 260 | 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 | 254 | data-settings-page-scroll |
| 262 | 255 | > |
| 263 | − <div | |
| 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 | − > | |
| 256 | + <WorkspacePageContainer width={width} className={cn("gap-12", className)}> | |
| 270 | 257 | {children} |
| 271 | − </div> | |
| 258 | + </WorkspacePageContainer> | |
| 272 | 259 | </div> |
| 273 | 260 | </SettingsSearchTargetProvider> |
| 274 | 261 | ); |
| @@ -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 | +}); | |
| @@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant · 16 unmodified lines | ||
| 17 | 17 | function ToggleGroup({ |
| 18 | 18 | className, |
| 19 | 19 | variant = "default", |
| 20 | − size = "default", | |
| 20 | + size = variant === "segmented" ? "segmented" : "default", | |
| 21 | 21 | orientation = "horizontal", |
| 22 | 22 | children, |
| 23 | 23 | ...props |
| @@ -30,11 +30,13 @@ function ToggleGroup({ · 6 unmodified lines | ||
| 30 | 30 | orientation === "horizontal" |
| 31 | 31 | ? "*:pointer-coarse:after:min-w-auto" |
| 32 | 32 | : "*:pointer-coarse:after:min-h-auto", |
| 33 | − variant === "default" | |
| 34 | − ? "gap-0.5" | |
| 35 | − : orientation === "horizontal" | |
| 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" | |
| 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", | |
| 33 | + variant === "segmented" | |
| 34 | + ? "gap-0.5 rounded-lg bg-input/40 p-0.5" | |
| 35 | + : variant === "default" | |
| 36 | + ? "gap-0.5" | |
| 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 | 40 | className, |
| 39 | 41 | )} |
| 40 | 42 | data-size={size} |
| @@ -18,6 +18,8 @@ const toggleVariants = cva( · 17 unmodified lines | ||
| 18 | 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 | 19 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", |
| 20 | 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 | 23 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", |
| 22 | 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( · 3 unmodified lines | ||
| 27 | 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 | 30 | outline: |
| 29 | 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 | }, |
| @@ -19,12 +19,19 @@ import { · 18 unmodified lines | ||
| 19 | 19 | formatUsd, |
| 20 | 20 | makeWindow, |
| 21 | 21 | } from "@t3tools/shared/usageFormat"; |
| 22 | −import { ScrollArea } from "../ui/scroll-area"; | |
| 23 | 22 | import { Button } from "../ui/button"; |
| 23 | +import { ScrollArea } from "../ui/scroll-area"; | |
| 24 | +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 24 | 25 | import { SidebarInset } from "../ui/sidebar"; |
| 25 | −import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; | |
| 26 | −import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | |
| 27 | −import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | |
| 26 | +import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 27 | +import { | |
| 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 | 35 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 29 | 36 | |
| 30 | 37 | const WINDOW_OPTIONS = [ |
| @@ -68,21 +75,6 @@ export function UsagePage() { · 37 unmodified lines | ||
| 68 | 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 | 78 | const selectWindow = (days: number) => { |
| 87 | 79 | setWindowSelection({ |
| 88 | 80 | days, |
| @@ -102,78 +94,113 @@ export function UsagePage() { · 13 unmodified lines | ||
| 102 | 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 | 193 | return ( |
| 107 | 194 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 108 | 195 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 109 | − {!isElectron && ( | |
| 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 | − )} | |
| 196 | + <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader> | |
| 134 | 197 | |
| 135 | 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"> | |
| 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 | − | |
| 199 | + <WorkspacePageContainer width="wide"> | |
| 173 | 200 | {settling ? ( |
| 174 | 201 | <> |
| 175 | 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() { · 3 unmodified lines | ||
| 183 | 210 | staleEnvironments={merged.staleEnvironments} |
| 184 | 211 | /> |
| 185 | 212 | |
| 186 | − {/* Cost first: the financial answer, then the provider split. */} | |
| 187 | − <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]"> | |
| 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"> | |
| 213 | + <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> | |
| 214 | + <div className="flex min-w-0 flex-col gap-5"> | |
| 191 | 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 | 216 | <span className="text-4xl font-semibold text-foreground tabular-nums"> |
| 196 | 217 | {metric === "cost" |
| 197 | − ? `${formatUsd(merged.costUsd)}*` | |
| 218 | + ? formatUsd(merged.costUsd) | |
| 198 | 219 | : formatTokens(merged.totalTokens)} |
| 199 | 220 | </span> |
| 200 | 221 | <span className="text-xs text-muted-foreground"> |
| 201 | 222 | {metric === "cost" |
| 202 | − ? "* if billed at full API rate" | |
| 203 | − : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} | |
| 223 | + ? `${formatCount(merged.sessions)} sessions · API estimate` | |
| 224 | + : `${formatCount(merged.sessions)} sessions`} | |
| 204 | 225 | </span> |
| 205 | 226 | </div> |
| 206 | 227 | |
| 207 | − {orderedProviders.map((provider) => { | |
| 208 | − const share = metric === "cost" ? provider.costShare : provider.tokenShare; | |
| 228 | + {PROVIDER_ORDER.map((provider) => { | |
| 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 | 236 | return ( |
| 210 | − <div key={provider.provider} className="flex flex-col gap-1.5"> | |
| 211 | − <div className="flex items-baseline justify-between"> | |
| 212 | − <span className="flex items-center gap-2 text-sm text-foreground"> | |
| 213 | − <ProviderMark provider={provider.provider} className="size-4" /> | |
| 214 | − {PROVIDER_PRESENTATION[provider.provider].label} | |
| 237 | + <div key={provider} className="flex flex-col gap-1"> | |
| 238 | + <div className="flex items-baseline justify-between gap-4"> | |
| 239 | + <span className="flex min-w-0 items-center gap-2 text-sm text-foreground"> | |
| 240 | + <span | |
| 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 | 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 | 258 | {metric === "cost" |
| 218 | − ? formatUsd(provider.costUsd) | |
| 219 | − : formatTokens(provider.totalTokens)} | |
| 259 | + ? formatUsd(totals?.costUsd ?? 0) | |
| 260 | + : formatTokens(totals?.totalTokens ?? 0)} | |
| 220 | 261 | </span> |
| 221 | 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 | 263 | <span className="text-xs text-muted-foreground"> |
| 232 | 264 | {metric === "cost" |
| 233 | − ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` | |
| 234 | − : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} | |
| 265 | + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` | |
| 266 | + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} | |
| 235 | 267 | </span> |
| 236 | 268 | </div> |
| 237 | 269 | ); |
| 238 | 270 | })} |
| 239 | 271 | </div> |
| 240 | 272 | |
| 241 | − <div className="flex flex-col gap-3"> | |
| 242 | − <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 243 | − <h2 className="text-sm font-medium text-foreground"> | |
| 244 | − {isPast24Hours ? "Hourly" : "Daily"}{" "} | |
| 245 | − {metric === "tokens" ? "processed tokens" : "cost"} | |
| 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> | |
| 273 | + <div className="flex min-w-0 flex-col gap-3"> | |
| 274 | + <h2 className="text-sm font-medium text-foreground"> | |
| 275 | + {isPast24Hours ? "Hourly" : "Daily"}{" "} | |
| 276 | + {metric === "tokens" ? "processed tokens" : "cost"} | |
| 277 | + </h2> | |
| 268 | 278 | <UsageProviderChart |
| 269 | 279 | days={days} |
| 270 | 280 | daily={merged.daily} |
| @@ -278,63 +288,46 @@ export function UsagePage() { · 7 unmodified lines | ||
| 278 | 288 | </div> |
| 279 | 289 | </section> |
| 280 | 290 | |
| 281 | − <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | |
| 282 | − <Metric | |
| 283 | − label="Processed tokens" | |
| 284 | − value={formatTokens(merged.totalTokens)} | |
| 285 | − detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`} | |
| 286 | − /> | |
| 287 | − <Metric | |
| 288 | − label="Cached input" | |
| 289 | − value={formatTokens(merged.cachedInputTokens)} | |
| 290 | − detail={`${formatPercent(cachedShare)} of observed input`} | |
| 291 | − /> | |
| 292 | − <Metric | |
| 293 | − label="Uncached input" | |
| 294 | − value={formatTokens(merged.uncachedInputTokens)} | |
| 295 | − detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`} | |
| 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 | − /> | |
| 291 | + <section className="flex flex-col gap-2"> | |
| 292 | + <h2 className="text-sm font-medium text-foreground">Totals</h2> | |
| 293 | + <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | |
| 294 | + <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} /> | |
| 295 | + <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} /> | |
| 296 | + <Metric | |
| 297 | + label="Uncached input" | |
| 298 | + value={formatTokens(merged.uncachedInputTokens)} | |
| 299 | + /> | |
| 300 | + <Metric label="Output" value={formatTokens(merged.outputTokens)} /> | |
| 301 | + <Metric | |
| 302 | + label="Cache savings" | |
| 303 | + value={formatUsd(merged.costQuality.cacheSavingsUsd)} | |
| 304 | + /> | |
| 305 | + </div> | |
| 311 | 306 | </section> |
| 312 | 307 | |
| 313 | 308 | <section className="flex flex-col gap-3"> |
| 314 | 309 | <div className="flex items-center justify-between gap-3"> |
| 315 | 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" }, | |
| 320 | − { value: "time", label: isPast24Hours ? "hour" : "day" }, | |
| 322 | + { value: "model", label: "Model" }, | |
| 323 | + { value: "time", label: isPast24Hours ? "Hour" : "Day" }, | |
| 321 | 324 | ] as const |
| 322 | 325 | ).map((option) => ( |
| 323 | − <button | |
| 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 | − > | |
| 326 | + <Toggle key={option.value} value={option.value}> | |
| 334 | 327 | {option.label} |
| 335 | − </button> | |
| 328 | + </Toggle> | |
| 336 | 329 | ))} |
| 337 | − </div> | |
| 330 | + </ToggleGroup> | |
| 338 | 331 | </div> |
| 339 | 332 | |
| 340 | 333 | {breakdown === "model" ? ( |
| @@ -358,7 +351,7 @@ export function UsagePage() { · 17 unmodified lines | ||
| 358 | 351 | merged.models.map((model) => ( |
| 359 | 352 | <tr |
| 360 | 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 | 356 | <td className="py-2 text-foreground"> |
| 364 | 357 | <span className="flex items-center gap-2"> |
| @@ -405,7 +398,7 @@ export function UsagePage() { · 40 unmodified lines | ||
| 405 | 398 | breakdownPeriods.map((period) => ( |
| 406 | 399 | <tr |
| 407 | 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 | 403 | <td className="py-2 text-foreground"> |
| 411 | 404 | {"hourStart" in period |
| @@ -435,7 +428,7 @@ export function UsagePage() { · 23 unmodified lines | ||
| 435 | 428 | </section> |
| 436 | 429 | </> |
| 437 | 430 | )} |
| 438 | − </div> | |
| 431 | + </WorkspacePageContainer> | |
| 439 | 432 | </ScrollArea> |
| 440 | 433 | </div> |
| 441 | 434 | </SidebarInset> |
| @@ -454,20 +447,11 @@ function ProviderMark({ · 12 unmodified lines | ||
| 454 | 447 | return <Mark className={cn("shrink-0", className)} aria-hidden />; |
| 455 | 448 | } |
| 456 | 449 | |
| 457 | −function Metric({ | |
| 458 | − label, | |
| 459 | − value, | |
| 460 | − detail, | |
| 461 | −}: { | |
| 462 | − readonly label: string; | |
| 463 | − readonly value: string; | |
| 464 | − readonly detail: string; | |
| 465 | −}) { | |
| 450 | +function Metric({ label, value }: { readonly label: string; readonly value: string }) { | |
| 466 | 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 | 453 | <span className="text-xs text-muted-foreground">{label}</span> |
| 469 | − <span className="text-lg text-foreground tabular-nums">{value}</span> | |
| 470 | − <span className="text-xs text-muted-foreground">{detail}</span> | |
| 454 | + <span className="text-base font-medium text-foreground tabular-nums">{value}</span> | |
| 471 | 455 | </div> |
| 472 | 456 | ); |
| 473 | 457 | } |
| @@ -571,70 +555,59 @@ function UsageDeviceStrip({ · 97 unmodified lines | ||
| 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, | |
| 579 | − * chart and metrics strip. No shimmer; blocks fill in exactly once when the | |
| 580 | − * last device answers. | |
| 559 | + * Static stand-in with the loaded page's shape. No shimmer; blocks fill in | |
| 560 | + * exactly once when the last device answers. | |
| 581 | 561 | */ |
| 582 | −function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { | |
| 562 | +function UsageSkeleton() { | |
| 583 | 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 | 566 | <div className="flex flex-col gap-5"> |
| 587 | 567 | <div className="flex flex-col gap-1"> |
| 588 | − <span className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 589 | − Raw token cost | |
| 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" /> | |
| 568 | + <div className="h-10 w-36 rounded-sm bg-muted" /> | |
| 569 | + <div className="h-4 w-32 rounded-sm bg-muted" /> | |
| 593 | 570 | </div> |
| 594 | − | |
| 595 | 571 | {PROVIDER_ORDER.map((provider) => ( |
| 596 | − <div key={provider} className="flex flex-col gap-1.5"> | |
| 597 | − <div className="flex items-center justify-between"> | |
| 598 | − <span className="flex items-center gap-2 text-sm text-foreground"> | |
| 572 | + <div key={provider} className="flex flex-col gap-1"> | |
| 573 | + <div className="flex min-h-5 items-center justify-between gap-4"> | |
| 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 | 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 | 582 | </span> |
| 602 | 583 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 603 | 584 | </div> |
| 604 | − <div className="h-1 w-full rounded-full bg-muted" /> | |
| 605 | − <div className="h-3 w-36 rounded-sm bg-muted" /> | |
| 585 | + <div className="h-4 w-36 rounded-sm bg-muted" /> | |
| 606 | 586 | </div> |
| 607 | 587 | ))} |
| 608 | 588 | </div> |
| 609 | 589 | |
| 610 | 590 | <div className="flex flex-col gap-3"> |
| 611 | − <h2 className="py-1 text-sm font-medium text-foreground"> | |
| 612 | − {resolution === "hour" ? "Hourly" : "Daily"} cost | |
| 613 | − </h2> | |
| 614 | − {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a | |
| 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 | − ))} | |
| 591 | + <div className="h-5 w-24 rounded-sm bg-muted" /> | |
| 592 | + <div className="flex flex-col gap-1"> | |
| 593 | + <div className="ml-16 h-56 rounded-sm bg-muted/35" /> | |
| 594 | + <div className="ml-16 h-3 rounded-sm bg-muted/35" /> | |
| 624 | 595 | </div> |
| 625 | 596 | </div> |
| 626 | 597 | </section> |
| 627 | 598 | |
| 628 | − <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5"> | |
| 629 | − {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | |
| 630 | − (label) => ( | |
| 631 | − <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3"> | |
| 632 | − <span className="text-xs text-muted-foreground">{label}</span> | |
| 633 | − <div className="my-1 h-5 w-16 rounded-sm bg-muted" /> | |
| 634 | − <div className="h-3 w-24 rounded-sm bg-muted" /> | |
| 635 | − </div> | |
| 636 | − ), | |
| 637 | − )} | |
| 599 | + <section className="flex flex-col gap-2"> | |
| 600 | + <h2 className="text-sm font-medium text-foreground">Totals</h2> | |
| 601 | + <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5"> | |
| 602 | + {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( | |
| 603 | + (label) => ( | |
| 604 | + <div key={label} className="flex flex-col gap-0.5"> | |
| 605 | + <span className="text-xs text-muted-foreground">{label}</span> | |
| 606 | + <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" /> | |
| 607 | + </div> | |
| 608 | + ), | |
| 609 | + )} | |
| 610 | + </div> | |
| 638 | 611 | </section> |
| 639 | 612 | </> |
| 640 | 613 | ); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | 5 | import { |
| @@ -68,13 +68,7 @@ function buildPeriodColumns( · 62 unmodified lines | ||
| 68 | 68 | }); |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | −/** | |
| 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 | − */ | |
| 71 | +/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ | |
| 78 | 72 | function monotoneTangents(points: readonly Point[]): readonly number[] { |
| 79 | 73 | const count = points.length; |
| 80 | 74 | if (count < 2) return [0]; |
| @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { · 34 unmodified lines | ||
| 115 | 109 | return tangents; |
| 116 | 110 | } |
| 117 | 111 | |
| 118 | −/** One cubic segment of a smoothed boundary. */ | |
| 119 | 112 | interface CurveSegment { |
| 120 | 113 | readonly from: Point; |
| 121 | 114 | readonly c1: Point; |
| @@ -123,7 +116,6 @@ interface CurveSegment { · 1 unmodified line | ||
| 123 | 116 | readonly to: Point; |
| 124 | 117 | } |
| 125 | 118 | |
| 126 | −/** Smoothed polyline through `points`, as explicit cubic control points. */ | |
| 127 | 119 | function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { |
| 128 | 120 | if (points.length < 2) return []; |
| 129 | 121 | const tangents = monotoneTangents(points); |
| @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { · 14 unmodified lines | ||
| 144 | 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 | 140 | const first = segments[0]; |
| 149 | 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 | 143 | for (const segment of segments) { |
| 152 | 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 · 25 unmodified lines | ||
| 179 | 171 | /** |
| 180 | 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 | |
| 183 | − * zero baseline rather than stacked. A stacked chart puts whichever provider is | |
| 184 | − * drawn last permanently above the other, which reads as "that one is bigger" | |
| 185 | − * even on days where it is not. | |
| 174 | + * Values are absolute, not cumulative: each provider is drawn from the same | |
| 175 | + * zero baseline so the chart never implies that one provider is always larger. | |
| 186 | 176 | * |
| 187 | 177 | * The chart paths and the hover readout both consume this, so the number under |
| 188 | 178 | * the cursor is by construction the number that was plotted rather than a |
| @@ -216,42 +206,44 @@ export function UsageProviderChart({ · 27 unmodified lines | ||
| 216 | 206 | ); |
| 217 | 207 | const [hoverIndex, setHoverIndex] = useState<number | null>(null); |
| 218 | 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 | 213 | if (periods.length === 0) { |
| 222 | 214 | return { |
| 223 | 215 | paths: [], |
| 224 | − ticks: [0] as readonly number[], | |
| 216 | + series: [] as readonly DayColumn[], | |
| 225 | 217 | stepX: 0, |
| 218 | + ticks: [0] as readonly number[], | |
| 226 | 219 | toY: () => VIEW_HEIGHT, |
| 227 | − series: [] as readonly DayColumn[], | |
| 228 | 220 | }; |
| 229 | 221 | } |
| 230 | 222 | |
| 231 | 223 | const columns = buildPeriodColumns(periods, byPeriod, metric); |
| 232 | − | |
| 233 | − // The scale tops out at the largest single provider-day, not the largest | |
| 234 | − // sum: layered series each measure from zero, so a combined peak would | |
| 235 | − // leave the plot permanently half empty. | |
| 224 | + // The scale tops out at the largest single provider-period, not the sum: | |
| 225 | + // layered series each measure from zero, so a combined peak would leave | |
| 226 | + // the plot permanently half empty. | |
| 236 | 227 | const peak = columns.reduce( |
| 237 | 228 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), |
| 238 | 229 | 0, |
| 239 | 230 | ); |
| 240 | 231 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 241 | 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 | |
| 243 | − // drawn at constant screen width, is not shaved off at a peak. | |
| 233 | + // Leave room above the top gridline so the constant-width stroke is not | |
| 234 | + // clipped when a series reaches the peak. | |
| 244 | 235 | const toY = (value: number) => |
| 245 | 236 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 246 | 237 | |
| 247 | 238 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 248 | − const curve = smoothCurve( | |
| 249 | − columns.map((column, dayIndex) => ({ | |
| 250 | − x: dayIndex * step, | |
| 251 | − y: toY(column.bands[providerIndex]?.value ?? 0), | |
| 252 | − })), | |
| 239 | + const line = curvePath( | |
| 240 | + smoothCurve( | |
| 241 | + columns.map((column, periodIndex) => ({ | |
| 242 | + x: periodIndex * step, | |
| 243 | + y: toY(column.bands[providerIndex]?.value ?? 0), | |
| 244 | + })), | |
| 245 | + ), | |
| 253 | 246 | ); |
| 254 | − const line = curvePath(curve, "M"); | |
| 255 | 247 | return { |
| 256 | 248 | provider, |
| 257 | 249 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| @@ -260,30 +252,76 @@ export function UsageProviderChart({ · 2 unmodified lines | ||
| 260 | 252 | }; |
| 261 | 253 | }); |
| 262 | 254 | |
| 263 | − // Paint the heavier series first so the lighter one is never buried under | |
| 264 | − // it. The fills are faint enough that the order barely shows, but the | |
| 265 | − // strokes are drawn in a second pass regardless, so neither can be hidden. | |
| 266 | − const ordered = [...built].sort((a, b) => b.total - a.total); | |
| 267 | − | |
| 268 | − return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; | |
| 255 | + // Paint the heavier series first so the lighter one is not buried. | |
| 256 | + return { | |
| 257 | + paths: built.toSorted((a, b) => b.total - a.total), | |
| 258 | + series: columns, | |
| 259 | + stepX: step, | |
| 260 | + ticks: tickValues, | |
| 261 | + toY, | |
| 262 | + }; | |
| 269 | 263 | }, [byPeriod, metric, periods]); |
| 270 | 264 | |
| 271 | 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 | 306 | const handleMove = useCallback( |
| 274 | 307 | (event: React.MouseEvent<HTMLDivElement>) => { |
| 275 | − const bounds = plotRef.current?.getBoundingClientRect(); | |
| 276 | − if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; | |
| 277 | − const fraction = (event.clientX - bounds.left) / bounds.width; | |
| 308 | + const plot = plotRef.current; | |
| 309 | + if (plot === null || periods.length === 0) return; | |
| 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 | 315 | const index = Math.round(fraction * (periods.length - 1)); |
| 316 | + hoverPositionRef.current = { x: localX, y: localY }; | |
| 317 | + positionTooltip(); | |
| 279 | 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 | 323 | const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; |
| 285 | 324 | const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; |
| 286 | − const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; | |
| 287 | 325 | const formatPeriod = (period: string) => |
| 288 | 326 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); |
| 289 | 327 | const formatTooltipPeriod = (period: string) => |
| @@ -311,7 +349,10 @@ export function UsageProviderChart({ · 21 unmodified lines | ||
| 311 | 349 | ref={plotRef} |
| 312 | 350 | className="relative h-56 flex-1" |
| 313 | 351 | onMouseMove={handleMove} |
| 314 | − onMouseLeave={() => setHoverIndex(null)} | |
| 352 | + onMouseLeave={() => { | |
| 353 | + hoverPositionRef.current = null; | |
| 354 | + setHoverIndex(null); | |
| 355 | + }} | |
| 315 | 356 | > |
| 316 | 357 | <svg |
| 317 | 358 | className="h-full w-full" |
| @@ -373,10 +414,11 @@ export function UsageProviderChart({ · 55 unmodified lines | ||
| 373 | 414 | |
| 374 | 415 | {hoveredPeriod === undefined ? null : ( |
| 375 | 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 | 419 | style={{ |
| 378 | − left: `${hoverLeft}%`, | |
| 379 | − transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)", | |
| 420 | + left: "var(--usage-tooltip-left, 0px)", | |
| 421 | + top: "var(--usage-tooltip-top, 0px)", | |
| 380 | 422 | }} |
| 381 | 423 | > |
| 382 | 424 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| @@ -423,21 +465,3 @@ export function UsageProviderChart({ · 40 unmodified lines | ||
| 423 | 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 | −} | |
| @@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = { · 25 unmodified lines | ||
| 26 | 26 | }, |
| 27 | 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 | 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; |
| @@ -138,6 +138,12 @@ describe("mergeUsage", () => { · 137 unmodified lines | ||
| 138 | 138 | "claude", |
| 139 | 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 | 149 | it("excludes an environment reporting an older contract version", () => { |
| @@ -248,6 +254,7 @@ describe("mergeUsage", () => { · 104 unmodified lines | ||
| 248 | 254 | ); |
| 249 | 255 | |
| 250 | 256 | expect(merged.sessions).toBe(1); |
| 257 | + expect(merged.providers[0]?.sessions).toBe(1); | |
| 251 | 258 | }); |
| 252 | 259 | |
| 253 | 260 | it("returns empty totals with no environments", () => { |
| @@ -257,6 +264,30 @@ describe("mergeUsage", () => { · 3 unmodified lines | ||
| 257 | 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 | 291 | it("derives hourly totals without losing the daily rollup", () => { |
| 261 | 292 | const merged = mergeUsage( |
| 262 | 293 | [ |
| @@ -25,6 +25,7 @@ export interface ProviderTotals { · 24 unmodified lines | ||
| 25 | 25 | readonly costUsd: number; |
| 26 | 26 | readonly totalTokens: number; |
| 27 | 27 | readonly records: number; |
| 28 | + readonly sessions: number; | |
| 28 | 29 | readonly costShare: number; |
| 29 | 30 | readonly tokenShare: number; |
| 30 | 31 | } |
| @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { · 104 unmodified lines | ||
| 135 | 136 | function ownedContribution( |
| 136 | 137 | environment: EnvironmentUsage, |
| 137 | 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 | 143 | const ownedProviders = new Set<UsageProviderKind>(); |
| 140 | − let sessions = 0; | |
| 144 | + const sessionsByProvider = new Map<UsageProviderKind, number>(); | |
| 141 | 145 | for (const source of environment.summary.sources) { |
| 142 | 146 | if (source.status === "missing") continue; |
| 143 | 147 | const key = fingerprintKey(source.fingerprint); |
| 144 | 148 | if (ownerByFingerprint.get(key) === environment.environmentId) { |
| 145 | − ownedProviders.add(source.fingerprint.provider); | |
| 149 | + const provider = source.fingerprint.provider; | |
| 150 | + ownedProviders.add(provider); | |
| 146 | 151 | // Distinct within a directory. Summing per-bucket session counts instead |
| 147 | 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 | 159 | return { |
| 152 | 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( · 71 unmodified lines | ||
| 228 | 236 | |
| 229 | 237 | const providerAccumulator = new Map< |
| 230 | 238 | UsageProviderKind, |
| 231 | − { costUsd: number; totalTokens: number; records: number } | |
| 239 | + { costUsd: number; totalTokens: number; records: number; sessions: number } | |
| 232 | 240 | >(); |
| 233 | 241 | const modelAccumulator = new Map< |
| 234 | 242 | string, |
| @@ -255,12 +263,21 @@ export function mergeUsage( · 20 unmodified lines | ||
| 255 | 263 | const contributingEnvironments: EnvironmentId[] = []; |
| 256 | 264 | |
| 257 | 265 | for (const environment of current) { |
| 258 | − const { buckets, sessions: environmentSessions } = ownedContribution( | |
| 259 | − environment, | |
| 260 | − ownerByFingerprint, | |
| 261 | − ); | |
| 266 | + const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); | |
| 262 | 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 | 282 | for (const bucket of buckets) { |
| 266 | 283 | const tokens = bucketTokens(bucket); |
| @@ -280,6 +297,7 @@ export function mergeUsage( · 13 unmodified lines | ||
| 280 | 297 | costUsd: 0, |
| 281 | 298 | totalTokens: 0, |
| 282 | 299 | records: 0, |
| 300 | + sessions: 0, | |
| 283 | 301 | }; |
| 284 | 302 | provider.costUsd += bucket.costUsd; |
| 285 | 303 | provider.totalTokens += tokens; |
| @@ -341,6 +359,7 @@ export function mergeUsage( · 55 unmodified lines | ||
| 341 | 359 | costUsd: totals.costUsd, |
| 342 | 360 | totalTokens: totals.totalTokens, |
| 343 | 361 | records: totals.records, |
| 362 | + sessions: totals.sessions, | |
| 344 | 363 | costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, |
| 345 | 364 | tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, |
| 346 | 365 | })) |