| author | |
| committer | |
| log | f0ebc628c6dd83fd0c7963078ad7778ce6028d0c |
| tree | a257c98ea9e3c745c69b75bb279bdd0c310abde8 |
| parent | 6ae9662d8ed215476e697adc12403ce035500828 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 42 insertions(+), 40 deletions(-)
apps/web/src/components/usage/UsagePage.tsx+6-6| ... | @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; | ... | @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; |
| 25 | import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; | 25 | import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; |
| 26 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; | 26 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; |
| 27 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 27 | import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 28 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 28 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 29 | 29 | ||
| 30 | const WINDOW_OPTIONS = [ | 30 | const WINDOW_OPTIONS = [ |
| 31 | { days: 1, label: "Past 24h" }, | 31 | { days: 1, label: "Past 24h" }, |
| ... | @@ -209,7 +209,7 @@ export function UsagePage() { | ... | @@ -209,7 +209,7 @@ export function UsagePage() { |
| 209 | <div className="flex items-baseline justify-between"> | 209 | <div className="flex items-baseline justify-between"> |
| 210 | <span className="flex items-center gap-2 text-sm text-foreground"> | 210 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| 211 | <ProviderMark provider={provider.provider} className="size-4" /> | 211 | <ProviderMark provider={provider.provider} className="size-4" /> |
| 212 | {PROVIDER_LABEL[provider.provider]} | 212 | {PROVIDER_PRESENTATION[provider.provider].label} |
| 213 | </span> | 213 | </span> |
| 214 | <span className="text-sm text-foreground tabular-nums"> | 214 | <span className="text-sm text-foreground tabular-nums"> |
| 215 | {metric === "cost" | 215 | {metric === "cost" |
| ... | @@ -222,7 +222,7 @@ export function UsagePage() { | ... | @@ -222,7 +222,7 @@ export function UsagePage() { |
| 222 | className="h-full" | 222 | className="h-full" |
| 223 | style={{ | 223 | style={{ |
| 224 | width: `${(share * 100).toFixed(1)}%`, | 224 | width: `${(share * 100).toFixed(1)}%`, |
| 225 | backgroundColor: PROVIDER_COLOR[provider.provider], | 225 | backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, |
| 226 | }} | 226 | }} |
| 227 | /> | 227 | /> |
| 228 | </div> | 228 | </div> |
| ... | @@ -385,7 +385,7 @@ export function UsagePage() { | ... | @@ -385,7 +385,7 @@ export function UsagePage() { |
| 385 | <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th> | 385 | <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th> |
| 386 | {PROVIDER_ORDER.map((provider) => ( | 386 | {PROVIDER_ORDER.map((provider) => ( |
| 387 | <th key={provider} className="py-2 text-right font-normal"> | 387 | <th key={provider} className="py-2 text-right font-normal"> |
| 388 | {PROVIDER_LABEL[provider]} | 388 | {PROVIDER_PRESENTATION[provider].label} |
| 389 | </th> | 389 | </th> |
| 390 | ))} | 390 | ))} |
| 391 | <th className="py-2 text-right font-normal">Total</th> | 391 | <th className="py-2 text-right font-normal">Total</th> |
| ... | @@ -448,7 +448,7 @@ function ProviderMark({ | ... | @@ -448,7 +448,7 @@ function ProviderMark({ |
| 448 | readonly provider: UsageProviderKind; | 448 | readonly provider: UsageProviderKind; |
| 449 | readonly className: string; | 449 | readonly className: string; |
| 450 | }) { | 450 | }) { |
| 451 | const Mark = PROVIDER_MARK[provider]; | 451 | const Mark = PROVIDER_PRESENTATION[provider].mark; |
| 452 | return <Mark className={cn("shrink-0", className)} aria-hidden />; | 452 | return <Mark className={cn("shrink-0", className)} aria-hidden />; |
| 453 | } | 453 | } |
| 454 | 454 | ||
| ... | @@ -595,7 +595,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) | ... | @@ -595,7 +595,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) |
| 595 | <div className="flex items-center justify-between"> | 595 | <div className="flex items-center justify-between"> |
| 596 | <span className="flex items-center gap-2 text-sm text-foreground"> | 596 | <span className="flex items-center gap-2 text-sm text-foreground"> |
| 597 | <ProviderMark provider={provider} className="size-4" /> | 597 | <ProviderMark provider={provider} className="size-4" /> |
| 598 | {PROVIDER_LABEL[provider]} | 598 | {PROVIDER_PRESENTATION[provider].label} |
| 599 | </span> | 599 | </span> |
| 600 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> | 600 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> |
| 601 | </div> | 601 | </div> |
apps/web/src/components/usage/UsageProviderChart.tsx+14-9| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | formatTokens, | 9 | formatTokens, |
| 10 | formatUsd, | 10 | formatUsd, |
| 11 | } from "@t3tools/shared/usageFormat"; | 11 | } from "@t3tools/shared/usageFormat"; |
| 12 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 12 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 13 | 13 | ||
| 14 | const VIEW_WIDTH = 960; | 14 | const VIEW_WIDTH = 960; |
| 15 | const VIEW_HEIGHT = 260; | 15 | const VIEW_HEIGHT = 260; |
| ... | @@ -339,14 +339,19 @@ export function UsageProviderChart({ | ... | @@ -339,14 +339,19 @@ export function UsageProviderChart({ |
| 339 | 339 | ||
| 340 | {/* Fills first, then every stroke, so no series covers another's line. */} | 340 | {/* Fills first, then every stroke, so no series covers another's line. */} |
| 341 | {paths.map(({ provider, area }) => ( | 341 | {paths.map(({ provider, area }) => ( |
| 342 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> | 342 | <path |
| 343 | key={provider} | ||
| 344 | d={area} | ||
| 345 | fill={PROVIDER_PRESENTATION[provider].color} | ||
| 346 | fillOpacity={0.12} | ||
| 347 | /> | ||
| 343 | ))} | 348 | ))} |
| 344 | {paths.map(({ provider, line }) => ( | 349 | {paths.map(({ provider, line }) => ( |
| 345 | <path | 350 | <path |
| 346 | key={provider} | 351 | key={provider} |
| 347 | d={line} | 352 | d={line} |
| 348 | fill="none" | 353 | fill="none" |
| 349 | stroke={PROVIDER_COLOR[provider]} | 354 | stroke={PROVIDER_PRESENTATION[provider].color} |
| 350 | strokeWidth={2} | 355 | strokeWidth={2} |
| 351 | vectorEffect="non-scaling-stroke" | 356 | vectorEffect="non-scaling-stroke" |
| 352 | /> | 357 | /> |
| ... | @@ -376,12 +381,12 @@ export function UsageProviderChart({ | ... | @@ -376,12 +381,12 @@ export function UsageProviderChart({ |
| 376 | > | 381 | > |
| 377 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> | 382 | <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> |
| 378 | {PROVIDER_ORDER.map((provider) => { | 383 | {PROVIDER_ORDER.map((provider) => { |
| 379 | const Mark = PROVIDER_MARK[provider]; | 384 | const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; |
| 380 | return ( | 385 | return ( |
| 381 | <div key={provider} className="flex items-center justify-between gap-3"> | 386 | <div key={provider} className="flex items-center justify-between gap-3"> |
| 382 | <span className="flex items-center gap-1.5 text-muted-foreground"> | 387 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 383 | <Mark className="size-3 shrink-0" aria-hidden /> | 388 | <Mark className="size-3 shrink-0" aria-hidden /> |
| 384 | {PROVIDER_LABEL[provider]} | 389 | {label} |
| 385 | </span> | 390 | </span> |
| 386 | <span className="text-foreground tabular-nums"> | 391 | <span className="text-foreground tabular-nums"> |
| 387 | {format( | 392 | {format( |
| ... | @@ -423,13 +428,13 @@ export function UsageChartLegend() { | ... | @@ -423,13 +428,13 @@ export function UsageChartLegend() { |
| 423 | return ( | 428 | return ( |
| 424 | <div className="flex items-center gap-4"> | 429 | <div className="flex items-center gap-4"> |
| 425 | {PROVIDER_ORDER.map((provider) => { | 430 | {PROVIDER_ORDER.map((provider) => { |
| 426 | // The marks carry the same fills as the bands, so they key the chart | 431 | // Brand marks keep monochrome providers identifiable even when their |
| 427 | // just as a colour swatch would. | 432 | // chart series use distinct colors. |
| 428 | const Mark = PROVIDER_MARK[provider]; | 433 | const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; |
| 429 | return ( | 434 | return ( |
| 430 | <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground"> | 435 | <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground"> |
| 431 | <Mark className="size-3.5 shrink-0" aria-hidden /> | 436 | <Mark className="size-3.5 shrink-0" aria-hidden /> |
| 432 | {PROVIDER_LABEL[provider]} | 437 | {label} |
| 433 | </span> | 438 | </span> |
| 434 | ); | 439 | ); |
| 435 | })} | 440 | })} |
apps/web/src/components/usage/usageProviders.ts+22-25| ... | @@ -2,32 +2,29 @@ import type { UsageProviderKind } from "@t3tools/contracts"; | ... | @@ -2,32 +2,29 @@ import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { ClaudeAI, type Icon, OpenAI } from "../Icons"; | 3 | import { ClaudeAI, type Icon, OpenAI } from "../Icons"; |
| 4 | 4 | ||
| 5 | /** | 5 | type UsageProviderPresentation = { |
| 6 | * Series and table order. The chart layers both providers from a shared zero | 6 | readonly label: string; |
| 7 | * baseline, so this only fixes the reading order of legends, tables and hover | 7 | readonly color: string; |
| 8 | * rows; it does not decide which series sits above the other. | 8 | readonly mark: Icon; |
| 9 | */ | ||
| 10 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; | ||
| 11 | |||
| 12 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | ||
| 13 | claude: "Claude Code", | ||
| 14 | codex: "Codex", | ||
| 15 | }; | ||
| 16 | |||
| 17 | /** Claude's brand orange against a neutral white for Codex. */ | ||
| 18 | export const PROVIDER_COLOR: Record<UsageProviderKind, string> = { | ||
| 19 | claude: "#d97757", | ||
| 20 | codex: "#e6e6e6", | ||
| 21 | }; | 9 | }; |
| 22 | 10 | ||
| 23 | /** | 11 | /** |
| 24 | * Brand marks, reused from the provider picker. | 12 | * Exhaustive presentation for providers supported by the usage contract. |
| 25 | * | 13 | * Declaration order is reused by every chart, table, legend, and skeleton, so |
| 26 | * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), | 14 | * adding a provider only requires its contract support and one entry here. |
| 27 | * which are the same colours as the chart bands, so swapping a colour dot for a | ||
| 28 | * mark keeps the series association intact rather than trading it away. | ||
| 29 | */ | 15 | */ |
| 30 | export const PROVIDER_MARK: Record<UsageProviderKind, Icon> = { | 16 | export const PROVIDER_PRESENTATION = { |
| 31 | claude: ClaudeAI, | 17 | codex: { |
| 32 | codex: OpenAI, | 18 | label: "Codex", |
| 33 | }; | 19 | color: "var(--foreground)", |
| 20 | mark: OpenAI, | ||
| 21 | }, | ||
| 22 | claude: { | ||
| 23 | label: "Claude Code", | ||
| 24 | color: "#d97757", | ||
| 25 | mark: ClaudeAI, | ||
| 26 | }, | ||
| 27 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; | ||
| 28 | |||
| 29 | /** The chart layers every series from zero, so order only controls how it is read. */ | ||
| 30 | export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; |