Skip to content

Commit 886195e

Browse files
t3dotggclaude
andauthored
fix(web): usage totals no longer jump while devices report in (#5772)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 288d8e3 commit 886195e

1 file changed

Lines changed: 147 additions & 26 deletions

File tree

apps/web/src/components/usage/UsagePage.tsx

Lines changed: 147 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { UsageProviderKind } from "@t3tools/contracts";
22
import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
3-
import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react";
3+
import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
44
import { useMemo, useState } from "react";
55

66
import { cn } from "../../lib/utils";
7-
import { useUsage } from "../../state/usage";
7+
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
88
import {
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
*/
398404
function 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+
*/
446+
function 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). */
498+
const 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+
*/
505+
function 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+
}

0 commit comments

Comments
 (0)