authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 22:05:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 19:05:16-07:00
log886195ec1e6335d46d34fb3819ab4f3440b42b6d
tree7bb2b1b3fe597a3746e4f554ec5f80a41ae9baa7
parent288d8e3457f0466da2cbef2eab648331c969b8a7
signature Signed by PGP key B5690EEEBB952194

fix(web): usage totals no longer jump while devices report in (#5772)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

1 files changed, 147 insertions(+), 26 deletions(-)

apps/web/src/components/usage/UsagePage.tsx+147-26
......@@ -1,10 +1,10 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
22import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
3import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react";
3import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
44import { useMemo, useState } from "react";
55
66import { cn } from "../../lib/utils";
7import { useUsage } from "../../state/usage";
7import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
88import {
99 enumerateDays,
1010 formatCount,
......@@ -37,6 +37,11 @@ export function UsagePage() {
3737 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
3838 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
3939
40 // Hold the content until every environment is terminal. Rendering merged
41 // totals while devices are still answering makes every number on the page
42 // jump as each one lands.
43 const settling = isPending || isPartial;
44
4045 const days = useMemo(
4146 () => enumerateDays(window.sinceDay, window.untilDay),
4247 [window.sinceDay, window.untilDay],
......@@ -112,19 +117,19 @@ export function UsagePage() {
112117 </div>
113118 </header>
114119
115 <UsageCoverageNotice
116 environments={environments}
117 duplicateSources={merged.duplicateSources}
118 staleEnvironments={merged.staleEnvironments}
119 isPartial={isPartial}
120 />
121
122 {isPending ? (
123 <p className="py-16 text-center text-sm text-muted-foreground">
124 Scanning provider transcripts…
125 </p>
120 {settling ? (
121 <>
122 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
123 <UsageSkeleton />
124 </>
126125 ) : (
127126 <>
127 <UsageCoverageNotice
128 environments={environments}
129 duplicateSources={merged.duplicateSources}
130 staleEnvironments={merged.staleEnvironments}
131 />
132
128133 {/* Cost first: the financial answer, then the provider split. */}
129134 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
130135 {/* The summary follows the chart toggle, so the headline and the
......@@ -391,37 +396,30 @@ function Metric({
391396}
392397
393398/**
394 * Says plainly when the totals are incomplete: an environment still answering,
395 * one that failed, or one whose transcripts another environment already
396 * reported.
399 * Says plainly when the totals are incomplete: an environment that failed, or
400 * one whose transcripts another environment already reported. Environments
401 * that are still answering never reach this notice; the page shows the
402 * loading skeleton until every one is terminal.
397403 */
398404function UsageCoverageNotice({
399405 environments,
400406 duplicateSources,
401407 staleEnvironments,
402 isPartial,
403408}: {
404 readonly environments: readonly {
405 environmentId: string;
406 label: string;
407 error: string | null;
408 isPending: boolean;
409 }[];
409 readonly environments: readonly EnvironmentUsageStatus[];
410410 readonly duplicateSources: readonly string[];
411411 readonly staleEnvironments: readonly string[];
412 readonly isPartial: boolean;
413412}) {
414413 const failed = environments.filter((environment) => environment.error !== null);
415414 const stale = environments.filter((environment) =>
416415 staleEnvironments.includes(environment.environmentId),
417416 );
418 if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) {
417 if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) {
419418 return null;
420419 }
421420
422421 return (
423422 <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground">
424 {isPartial ? <span>Some environments are still reporting. Totals are partial.</span> : null}
425423 {failed.map((environment) => (
426424 <span key={environment.label}>{environment.label} could not report usage.</span>
427425 ))}
......@@ -439,3 +437,126 @@ function UsageCoverageNotice({
439437 </div>
440438 );
441439}
440
441/**
442 * Per-device progress while the page waits for every environment to answer.
443 * Only rendered with two or more devices; a lone device has nothing to
444 * enumerate.
445 */
446function UsageDeviceStrip({
447 environments,
448}: {
449 readonly environments: readonly EnvironmentUsageStatus[];
450}) {
451 const scanning = environments.filter(
452 (environment) => environment.summary === null && environment.error === null,
453 );
454 return (
455 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs">
456 {environments.map((environment) => {
457 if (environment.summary !== null) {
458 return (
459 <span
460 key={environment.environmentId}
461 className="flex items-center gap-1 text-foreground"
462 >
463 <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />
464 {environment.label}
465 </span>
466 );
467 }
468 if (environment.error !== null) {
469 return (
470 <span
471 key={environment.environmentId}
472 className="flex items-center gap-1 text-destructive"
473 >
474 <XIcon className="size-3" aria-hidden />
475 {environment.label}
476 </span>
477 );
478 }
479 return (
480 <span
481 key={environment.environmentId}
482 className="animate-status-pulse text-muted-foreground"
483 >
484 {environment.label}…
485 </span>
486 );
487 })}
488 <span className="ms-auto text-muted-foreground">
489 {scanning.length === 1
490 ? "1 device still scanning"
491 : `${scanning.length} devices still scanning`}
492 </span>
493 </div>
494 );
495}
496
497/** Deterministic bar heights (each unique: they double as keys). */
498const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
499
500/**
501 * Static stand-in with the loaded page's shape: headline, provider split,
502 * chart and metrics strip. No shimmer; blocks fill in exactly once when the
503 * last device answers.
504 */
505function UsageSkeleton() {
506 return (
507 <>
508 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
509 <div className="flex flex-col gap-5">
510 <div className="flex flex-col gap-1">
511 <span className="text-xs tracking-wide text-muted-foreground uppercase">
512 Raw token cost
513 </span>
514 <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
515 <div className="h-3 w-28 rounded-sm bg-muted" />
516 </div>
517
518 {PROVIDER_ORDER.map((provider) => (
519 <div key={provider} className="flex flex-col gap-1.5">
520 <div className="flex items-center justify-between">
521 <span className="flex items-center gap-2 text-sm text-foreground">
522 <ProviderMark provider={provider} className="size-4" />
523 {PROVIDER_LABEL[provider]}
524 </span>
525 <div className="h-3.5 w-14 rounded-sm bg-muted" />
526 </div>
527 <div className="h-1 w-full rounded-full bg-muted" />
528 <div className="h-3 w-36 rounded-sm bg-muted" />
529 </div>
530 ))}
531 </div>
532
533 <div className="flex flex-col gap-3">
534 <h2 className="py-1 text-sm font-medium text-foreground">Daily cost</h2>
535 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
536 relayout when the real chart swaps in. */}
537 <div className="flex h-56 items-end gap-1 pl-16">
538 {SKELETON_BAR_HEIGHTS.map((height) => (
539 <div
540 key={height}
541 className="flex-1 rounded-sm bg-muted"
542 style={{ height: `${height}%` }}
543 />
544 ))}
545 </div>
546 </div>
547 </section>
548
549 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
550 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
551 (label) => (
552 <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">
553 <span className="text-xs text-muted-foreground">{label}</span>
554 <div className="my-1 h-5 w-16 rounded-sm bg-muted" />
555 <div className="h-3 w-24 rounded-sm bg-muted" />
556 </div>
557 ),
558 )}
559 </section>
560 </>
561 );
562}