11import type { UsageProviderKind } from "@t3tools/contracts" ;
22import { useCanGoBack , useNavigate , useRouter } from "@tanstack/react-router" ;
3- import { ArrowLeftIcon , RefreshCwIcon } from "lucide-react" ;
3+ import { ArrowLeftIcon , CheckIcon , RefreshCwIcon , XIcon } from "lucide-react" ;
44import { useMemo , useState } from "react" ;
55
66import { cn } from "../../lib/utils" ;
7- import { useUsage } from "../../state/usage" ;
7+ import { 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+ */
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