@jeffusion/bungee-core 5.3.2 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,17 +166,27 @@
166
166
  "page.title": "Token Statistics",
167
167
  "page.viewModels": "View model statistics",
168
168
  "ui.refresh": "Refresh",
169
+ "ui.previousInterval": "vs previous {interval}",
170
+ "ui.minutes": "min",
171
+ "ui.hours": "h",
172
+ "ui.newActivity": "New",
169
173
  "page.range": "Time range",
170
174
  "page.range.1h": "1 hour",
171
175
  "page.range.12h": "12 hours",
172
176
  "page.range.24h": "24 hours",
177
+ "page.range.1d": "Last 1 day",
178
+ "page.range.7d": "Last 7 days",
179
+ "page.range.30d": "Last 30 days",
180
+ "page.range.day": "Today",
181
+ "page.range.week": "This week",
182
+ "page.range.month": "This month",
173
183
  "page.updatedAt": "Updated at",
174
184
  "page.tokens": "Token",
175
- "page.activity": "Activity over time",
176
- "page.composition": "Token composition",
185
+ "page.activity": "Token Trend",
177
186
  "page.cacheRead": "Cache read",
178
187
  "page.cacheWrite": "Cache write",
179
188
  "page.sort": "Sort models",
189
+ "page.models": "Model usage",
180
190
  "page.sort.tokens": "Token",
181
191
  "page.sort.cost": "Cost",
182
192
  "page.searchModels": "Search models",
@@ -238,17 +248,27 @@
238
248
  "page.title": "Token 统计",
239
249
  "page.viewModels": "查看模型统计",
240
250
  "ui.refresh": "刷新",
251
+ "ui.previousInterval": "较上个{interval}",
252
+ "ui.minutes": "分钟",
253
+ "ui.hours": "小时",
254
+ "ui.newActivity": "新增",
241
255
  "page.range": "时间范围",
242
256
  "page.range.1h": "1 小时",
243
257
  "page.range.12h": "12 小时",
244
258
  "page.range.24h": "24 小时",
259
+ "page.range.1d": "最近1天",
260
+ "page.range.7d": "最近7天",
261
+ "page.range.30d": "最近30天",
262
+ "page.range.day": "本日",
263
+ "page.range.week": "本周",
264
+ "page.range.month": "本月",
245
265
  "page.updatedAt": "更新于",
246
266
  "page.tokens": "Token",
247
- "page.activity": "时间活动",
248
- "page.composition": "Token 构成",
267
+ "page.activity": "Token 趋势",
249
268
  "page.cacheRead": "缓存读取",
250
269
  "page.cacheWrite": "缓存写入",
251
270
  "page.sort": "模型排序",
271
+ "page.models": "模型用量",
252
272
  "page.sort.tokens": "Token",
253
273
  "page.sort.cost": "费用",
254
274
  "page.searchModels": "搜索模型",
@@ -4,26 +4,33 @@
4
4
  import { _, formatCompactNumber, type TimeRange } from '@bungee/plugin-sdk';
5
5
  import { getPluginText } from '$utils/plugin-i18n';
6
6
  import { LoadingIndicator } from '$components/industrial';
7
+ import KpiTrendValue from '$components/industrial/KpiTrendValue.svelte';
7
8
  import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
8
9
  import { estimatedTokens, formatEstimatedUsd, formatTokenCount, usagePresentation } from './labels';
9
10
  import { getStatsResource } from './stats';
10
11
  import type { StatsState, StatsResource } from './stats-resource';
12
+ import { overviewTrends } from './overview-trends';
11
13
 
12
14
  let { pluginName = 'token-stats', selectedRange = '24h', onHeaderChange }:
13
15
  { pluginName?: string; selectedRange?: TimeRange; onHeaderChange?: NativeWidgetHeaderChange } = $props();
14
16
  let state: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
15
17
  let resource: StatsResource | undefined = $state();
16
- const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
18
+ const t = (key: string, values?: Record<string, string>) => $isLoading ? '' : getPluginText(key, pluginName,
19
+ (id, options) => $_(id, { ...options, values }));
17
20
  const usage = $derived(state.data ? usagePresentation(state.data) : null);
21
+ const trends = $derived(overviewTrends(state.error ? null : state.data));
22
+ const interval = $derived(state.data?.bucketMs);
23
+ const comparisonCaption = $derived(interval ? t('ui.previousInterval', { interval: interval < 3_600_000
24
+ ? `${interval / 60_000}${t('ui.minutes')}` : `${interval / 3_600_000}${t('ui.hours')}` }) : '');
18
25
  $effect(() => {
19
- const nextResource = getStatsResource(pluginName, selectedRange, 'model');
26
+ const nextResource = getStatsResource(pluginName, selectedRange, 'time');
20
27
  resource = nextResource;
21
28
  return nextResource.subscribe(next => { state = next; });
22
29
  });
23
30
  $effect(() => {
24
31
  const report = onHeaderChange;
25
32
  const header = { summary: selectedRange, refresh: { label: t('ui.refresh'), busy: state.busy,
26
- disabled: !resource, run: () => { void resource?.refresh(); } } };
33
+ disabled: !resource, run: () => { void resource?.refresh(); } }, footer: trendFooter };
27
34
  untrack(() => report?.(header));
28
35
  });
29
36
  onMount(() => {
@@ -32,6 +39,19 @@
32
39
  });
33
40
  </script>
34
41
 
42
+ {#snippet trendFooter()}
43
+ <div class="overview-trends w-full" data-testid="token-stats-overview-trends">
44
+ {#each trends as item}
45
+ {@const value = item.isNew ? t('ui.newActivity') : item.percent == null ? '—' : `${item.percent >= 0 ? '+' : ''}${item.percent.toFixed(1)}%`}
46
+ <div class="flex min-w-0 flex-wrap items-center gap-x-1.5" data-testid={`token-stats-trend-${item.metric}`}
47
+ aria-label={`${t(item.metric === 'cost' ? 'ui.estimatedCostUsd' : `ui.${item.metric}`)} ${value} ${comparisonCaption}`}>
48
+ <KpiTrendValue {value} change={item.change} direction={item.metric === 'cost' ? 'down' : 'up'} clamp />
49
+ {#if comparisonCaption}<span class="nx-label-sm">{comparisonCaption}</span>{/if}
50
+ </div>
51
+ {/each}
52
+ </div>
53
+ {/snippet}
54
+
35
55
  <div class="kpi-metric-row w-full min-w-0" data-testid="token-stats-overview">
36
56
  {#if state.busy && !state.data}
37
57
  <LoadingIndicator size="sm" height="sm" label={t('ui.loading')} />
@@ -43,12 +63,12 @@
43
63
  {@const fullValue = metric === 'cost' ? formatEstimatedUsd(state.data?.estimatedCostUsd)
44
64
  : formatTokenCount(metric === 'input' ? usage?.input : usage?.output)}
45
65
  {@const value = metric === 'cost'
46
- ? (state.data?.estimatedCostUsd != null && Number.isFinite(state.data.estimatedCostUsd) && state.data.estimatedCostUsd >= 1000
66
+ ? (state.data?.estimatedCostUsd != null && Number.isFinite(state.data.estimatedCostUsd) && state.data.estimatedCostUsd >= 0
47
67
  ? `$${formatCompactNumber(state.data.estimatedCostUsd)}` : fullValue)
48
68
  : formatCompactNumber(metric === 'input' ? usage?.input : usage?.output)}
49
69
  <div class="min-w-0" data-testid={`token-stats-metric-${metric}`}>
50
70
  <div class="nx-label mb-2">{t(metric === 'cost' ? 'ui.estimatedCostUsd' : `ui.${metric}`)}</div>
51
- <div class="overview-value nx-metric truncate text-zinc-50" title={fullValue} aria-label={fullValue}>{value}</div>
71
+ <div class="overview-value nx-metric text-zinc-50" title={fullValue} aria-label={fullValue}>{value}</div>
52
72
  {#if metric !== 'cost' && state.data && (estimatedTokens(state.data, metric as 'input' | 'output') ?? 0) > 0}
53
73
  <p class="mt-1 text-[10px] text-zinc-400">{t('ui.includesEstimate')}</p>
54
74
  {/if}
@@ -61,13 +81,15 @@
61
81
  </div>
62
82
 
63
83
  <style>
64
- .overview-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
84
+ .overview-metrics, .overview-trends { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
85
+ .overview-trends > div + div { padding-left: 16px; }
65
86
  .overview-metrics > div + div { border-left: 1px solid var(--nx-edge); padding-left: 16px; }
66
- .overview-value { font-size: clamp(20px, 2.3vw, 32px); }
87
+ .overview-value { font-size: clamp(18px, 2vw, 28px); }
67
88
  @container (max-width: 380px) {
68
- .overview-metrics { gap: 8px; }
89
+ .overview-metrics, .overview-trends { gap: 8px; }
90
+ .overview-trends > div + div { padding-left: 8px; }
69
91
  .overview-metrics > div + div { padding-left: 8px; }
70
- .overview-value { font-size: 20px; }
92
+ .overview-value { font-size: 16px; }
71
93
  }
72
94
  @container (max-width: 220px) {
73
95
  .overview-metrics { grid-template-columns: 1fr; }
@@ -1,18 +1,18 @@
1
1
  <script lang="ts">
2
2
  import { isLoading, locale } from 'svelte-i18n';
3
3
  import RefreshCw from 'lucide-svelte/icons/refresh-cw';
4
- import { _ } from '@bungee/plugin-sdk';
4
+ import { _, formatCompactNumber } from '@bungee/plugin-sdk';
5
5
  import { getPluginText } from '$utils/plugin-i18n';
6
- import { KpiCard, PanelCard, MetricBar, BSegmentedControl, LoadingIndicator } from '$components/industrial';
6
+ import { KpiCard, PanelCard, MetricBar, BSelect, BSegmentedControl, LoadingIndicator } from '$components/industrial';
7
7
  import { Button } from '$components/ui/button';
8
8
  import { Input } from '$components/ui/input';
9
9
  import UsageTimeChart from './UsageTimeChart.svelte';
10
10
  import { getStatsResource } from './stats';
11
- import { cacheDetail, formatEstimatedUsd, formatTokenCount, modelTokenTotal, tokenComposition, usagePresentation, type TokenRange } from './labels';
11
+ import { formatCacheInputPercentage, formatEstimatedUsd, formatTokenCount, modelTokenTotal, tokenComposition, usagePresentation, type TokenPageRange } from './labels';
12
12
  import type { StatsState, StatsResource } from './stats-resource';
13
13
 
14
14
  let { pluginName = 'token-stats' }: { pluginName?: string } = $props();
15
- let range: TokenRange = $state('24h');
15
+ let range: TokenPageRange = $state('day');
16
16
  let sort = $state('tokens');
17
17
  let search = $state('');
18
18
  let models: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
@@ -20,9 +20,11 @@
20
20
  let modelResource: StatsResource | undefined = $state();
21
21
  let timeResource: StatsResource | undefined = $state();
22
22
  const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
23
- const display = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
23
+ const display = formatCompactNumber;
24
+ const fullCount = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
25
+ const displayCost = (value: unknown) => typeof value === 'number' && value >= 1000
26
+ ? `$${formatCompactNumber(value)}` : formatEstimatedUsd(value);
24
27
  const usage = $derived(models.data ? usagePresentation(models.data) : null);
25
- const composition = $derived(tokenComposition(models.data ?? {}));
26
28
  const modelLabel = (id: string) => id === 'unknown' ? t('ui.unknownModel') : id;
27
29
  const parts = (row: Parameters<typeof tokenComposition>[0]) => {
28
30
  const c = tokenComposition(row);
@@ -33,18 +35,15 @@
33
35
  { key: 'cacheWrite', label: t('page.cacheWrite'), value: c.cacheWrite, tone: 'danger' as const },
34
36
  ];
35
37
  };
36
- const knownPart = (key: string) => key === 'input' ? usage?.input !== undefined
37
- : key === 'output' ? usage?.output !== undefined
38
- : cacheDetail(key === 'cacheRead' ? models.data?.cacheReadTokens : models.data?.cacheWriteTokens) !== undefined;
39
38
  const rankAmount = (row: NonNullable<typeof models.data>['data'][number]) => sort === 'cost'
40
39
  ? row.estimatedCostUsd ?? -1 : modelTokenTotal(row) ?? -1;
41
40
  const rows = $derived.by(() => [...models.data?.data ?? []]
42
41
  .filter(row => modelLabel(row.dimension).toLowerCase().includes(search.trim().toLowerCase()))
43
42
  .sort((a, b) => rankAmount(b) - rankAmount(a) || a.dimension.localeCompare(b.dimension)));
44
- const maxRank = $derived(Math.max(0, ...rows.map(rankAmount)));
43
+ const maxTokens = $derived(Math.max(0, ...rows.map(row => modelTokenTotal(row) ?? 0)));
45
44
  const busy = $derived(models.busy || time.busy);
46
45
  const refreshedAt = $derived(Math.min(models.refreshedAt, time.refreshedAt));
47
- const ranges = $derived(['1h', '12h', '24h'].map(value => ({ value, label: t(`page.range.${value}`) })));
46
+ const ranges = $derived(['day', 'week', 'month', '1d', '7d', '30d'].map(value => ({ value, label: t(`page.range.${value}`) })));
48
47
  const sorts = $derived(['tokens', 'cost'].map(value => ({ value, label: t(`page.sort.${value}`) })));
49
48
  $effect(() => {
50
49
  const nextModel = getStatsResource(pluginName, range, 'model');
@@ -59,7 +58,8 @@
59
58
 
60
59
  <div class="min-w-0 space-y-4" data-testid="token-stats-page">
61
60
  <div class="flex flex-wrap items-center justify-between gap-3">
62
- <BSegmentedControl options={ranges} value={range} onchange={value => range = value as TokenRange} ariaLabel={t('page.range')} />
61
+ <BSelect options={ranges} value={range} onchange={value => { if (typeof value === 'string') range = value as TokenPageRange; }}
62
+ ariaLabel={t('page.range')} width="w-40" size="small" />
63
63
  <div class="flex min-w-0 flex-wrap items-center gap-3">
64
64
  <span class="font-mono text-[10px] text-zinc-400" aria-live="polite">{refreshedAt ? `${t('page.updatedAt')} ${new Date(refreshedAt).toLocaleTimeString($locale ?? undefined)}` : ''}</span>
65
65
  <Button variant="ghost" size="sm" disabled={busy} onclick={refresh} aria-label={t('ui.refresh')} aria-busy={busy}>
@@ -77,10 +77,10 @@
77
77
  <LoadingIndicator label={t('ui.loading')} height="sm" />
78
78
  {:else if models.data}
79
79
  <div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary">
80
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.input')} value={display(usage?.input)} />
81
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.output')} value={display(usage?.output)} />
82
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('page.tokens')} value={display(modelTokenTotal(models.data))} />
83
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.estimatedCostUsd')} value={formatEstimatedUsd(models.data.estimatedCostUsd)} />
80
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.input')} value={display(usage?.input)} valueTitle={fullCount(usage?.input)} />
81
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.output')} value={display(usage?.output)} valueTitle={fullCount(usage?.output)} />
82
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('page.tokens')} value={display(modelTokenTotal(models.data))} valueTitle={fullCount(modelTokenTotal(models.data))} />
83
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.estimatedCostUsd')} value={displayCost(models.data.estimatedCostUsd)} valueTitle={formatEstimatedUsd(models.data.estimatedCostUsd)} />
84
84
  </div>
85
85
  {#if usage?.state === 'empty'}<p class="py-8 text-center text-sm text-zinc-400" data-testid="token-stats-page-empty">{t('ui.noData')}</p>{/if}
86
86
  {/if}
@@ -89,27 +89,16 @@
89
89
  <div class="h-[240px] min-w-0 sm:h-[300px]">
90
90
  {#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} />
91
91
  {:else if time.data && usagePresentation(time.data).positive}
92
- <UsageTimeChart stats={time.data} {range} refreshedAt={time.refreshedAt} {pluginName} />
92
+ <UsageTimeChart stats={time.data} {range} refreshedAt={time.refreshedAt} {pluginName} presentation="page" />
93
93
  {:else}<p class="flex h-full items-center justify-center text-sm text-zinc-400">{t(time.data && usagePresentation(time.data).state === 'empty' ? 'ui.noData' : 'ui.noBreakdown')}</p>{/if}
94
94
  </div>
95
95
  </PanelCard>
96
96
 
97
97
  {#if models.data}
98
- <PanelCard title={t('page.composition')} tag="TOKEN">
99
- <MetricBar label={t('page.tokens')} value={composition.total} max={composition.total} valueLabel={display(modelTokenTotal(models.data))}
100
- segments={parts(models.data)} tone="neutral" />
101
- <dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4" data-testid="token-stats-composition">
102
- {#each parts(models.data) as part}
103
- <div><dt class="nx-label">{part.label}</dt><dd class="mt-1 font-mono text-xs text-zinc-200">{knownPart(part.key) ? display(part.value) : '—'}
104
- <span class="ml-2 text-zinc-400">{knownPart(part.key) && composition.total > 0 ? `${(part.value / composition.total * 100).toFixed(1)}%` : '—'}</span></dd></div>
105
- {/each}
106
- </dl>
107
- </PanelCard>
108
-
109
- <PanelCard title={t('dimension.model')} tag={`${rows.length} / ${models.data.data.length}`}>
110
- <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
111
- <Input bind:value={search} aria-label={t('page.searchModels')} placeholder={t('page.searchModels')} class="w-full sm:w-64" />
112
- <BSegmentedControl options={sorts} bind:value={sort} ariaLabel={t('page.sort')} />
98
+ <PanelCard title={t('page.models')} tag={`${rows.length} / ${models.data.data.length}`}>
99
+ <div class="mb-3 flex flex-wrap items-center gap-3">
100
+ <BSegmentedControl options={sorts} bind:value={sort} ariaLabel={t('page.sort')} class="shrink-0" />
101
+ <Input bind:value={search} aria-label={t('page.searchModels')} placeholder={t('page.searchModels')} class="min-w-[140px] flex-1 sm:max-w-64" />
113
102
  </div>
114
103
  <ol class="space-y-4" data-testid="token-stats-model-list">
115
104
  {#each rows as row (row.dimension)}
@@ -117,16 +106,15 @@
117
106
  <li class="min-w-0 border-b border-carbon-600 pb-4 last:border-0 last:pb-0" data-testid="token-stats-model-row">
118
107
  <div class="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">
119
108
  <strong class="min-w-0 break-all font-mono text-xs font-normal text-zinc-200">{modelLabel(row.dimension)}</strong>
120
- <span class="font-mono text-[11px] text-zinc-400">{display(total)} Token · {formatEstimatedUsd(row.estimatedCostUsd)}</span>
109
+ <span class="font-mono text-[11px] text-zinc-400"><span title={fullCount(total)}>{display(total)} Token</span> · <span title={formatEstimatedUsd(row.estimatedCostUsd)}>{displayCost(row.estimatedCostUsd)}</span></span>
121
110
  </div>
122
- <MetricBar label={`${modelLabel(row.dimension)} · ${t(`page.sort.${sort}`)}`} value={Math.max(0, rankAmount(row))} max={maxRank}
123
- valueLabel={sort === 'cost' ? formatEstimatedUsd(row.estimatedCostUsd) : display(total)}
124
- tone={sort === 'tokens' ? 'neutral' : 'accent'} segments={sort === 'tokens' ? parts(row) : []} headless />
125
- <dl class="mt-2 flex flex-wrap gap-x-5 gap-y-1 font-mono text-[10px] text-zinc-400">
126
- <div>{t('ui.input')} <span class="text-zinc-200">{display(usagePresentation(row).input)}</span></div>
127
- <div>{t('ui.output')} <span class="text-zinc-200">{display(usagePresentation(row).output)}</span></div>
128
- <div>{t('page.cacheRead')} <span class="text-zinc-200">{display(row.cacheReadTokens)}</span></div>
129
- <div>{t('page.cacheWrite')} <span class="text-zinc-200">{display(row.cacheWriteTokens)}</span></div>
111
+ <MetricBar label={`${modelLabel(row.dimension)} · ${t('page.tokens')}`} value={Math.max(0, total ?? 0)} max={maxTokens}
112
+ valueLabel={display(total)} tone="neutral" segments={parts(row)} headless />
113
+ <dl class="mt-2 flex flex-wrap gap-x-5 gap-y-1 font-mono text-[11px] text-zinc-400">
114
+ <div>{t('ui.input')} <span class="text-zinc-200" title={fullCount(usagePresentation(row).input)}>{display(usagePresentation(row).input)}</span></div>
115
+ <div>{t('ui.output')} <span class="text-zinc-200" title={fullCount(usagePresentation(row).output)}>{display(usagePresentation(row).output)}</span></div>
116
+ <div>{t('page.cacheRead')} <span class="text-zinc-200" title={fullCount(row.cacheReadTokens)}>{display(row.cacheReadTokens)}</span> ({formatCacheInputPercentage(row.cacheReadTokens, row)})</div>
117
+ <div>{t('page.cacheWrite')} <span class="text-zinc-200" title={fullCount(row.cacheWriteTokens)}>{display(row.cacheWriteTokens)}</span> ({formatCacheInputPercentage(row.cacheWriteTokens, row)})</div>
130
118
  </dl>
131
119
  </li>
132
120
  {:else}<li class="py-6 text-center text-xs text-zinc-400">{t(search ? 'page.noModels' : 'ui.noData')}</li>{/each}
@@ -1,24 +1,39 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
3
2
  import { isLoading, locale } from 'svelte-i18n';
4
3
  import { getPluginText } from '$utils/plugin-i18n';
5
4
  import * as Tooltip from '$components/ui/tooltip';
6
- import { Bar, ChartJS, BarElement, CategoryScale, LinearScale, _, type ChartData, type ChartOptions } from '@bungee/plugin-sdk';
7
- import type { Plugin } from 'chart.js';
8
- import { buildTimeSeries, formatTokenCount, modelColorIndex, OTHER_MODEL, timeAxisLabels, type TimeBucket, type TokenRange } from './labels';
5
+ import { Bar, ChartJS, BarElement, CategoryScale, LinearScale, Tooltip as ChartTooltip, _, formatCompactNumber, type ChartData, type ChartOptions } from '@bungee/plugin-sdk';
6
+ import type { Plugin, TooltipModel } from 'chart.js';
7
+ import { buildTimeSeries, formatTokenCount, modelColorIndex, OTHER_MODEL, timeAxisLabels, type TimeBucket, type TokenStatsRange } from './labels';
9
8
  import type { StatsResponse } from './stats-resource';
10
9
 
11
- ChartJS.register(BarElement, CategoryScale, LinearScale);
12
- let { stats, range, refreshedAt, pluginName = 'token-stats' }:
13
- { stats: StatsResponse; range: TokenRange; refreshedAt: number; pluginName?: string } = $props();
10
+ ChartJS.register(BarElement, CategoryScale, LinearScale, ChartTooltip);
11
+ let { stats, range, refreshedAt, pluginName = 'token-stats', presentation = 'dashboard' }:
12
+ { stats: StatsResponse; range: TokenStatsRange; refreshedAt: number; pluginName?: string; presentation?: 'dashboard' | 'page' } = $props();
13
+ const pageChart = $derived(presentation === 'page');
14
14
  const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
15
- const display = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
16
- const trend = $derived(buildTimeSeries(stats.data, range, stats.bucketMs ?? 0, refreshedAt));
17
- const axisTicks = $derived(trend.buckets.length ? timeAxisLabels([
15
+ const display = formatCompactNumber;
16
+ const fullCount = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
17
+ let chart = $state.raw<ChartJS<'bar'> | null>(null);
18
+ let bucketTriggers: HTMLDivElement | undefined = $state();
19
+ const trend = $derived(buildTimeSeries(stats.data, range, stats.bucketMs ?? 0, stats.asOfMs ?? refreshedAt, stats.bucketStarts));
20
+ const dayBuckets = $derived(['7d', '30d', 'week', 'month'].includes(range));
21
+ const tickStarts = $derived(trend.buckets.length ? [
18
22
  trend.buckets[0].startMs, trend.buckets[Math.floor(trend.buckets.length / 2)].startMs,
19
23
  trend.buckets[trend.buckets.length - 1].startMs,
20
- ], $locale ?? undefined) : ['', '', '']);
24
+ ] : []);
25
+ const axisTicks = $derived(!tickStarts.length ? ['', '', ''] : dayBuckets
26
+ ? tickStarts.map(start => new Intl.DateTimeFormat($locale ?? undefined, { month: 'numeric', day: 'numeric' }).format(start))
27
+ : timeAxisLabels(tickStarts, $locale ?? undefined));
21
28
  const denseAxis = $derived(axisTicks.join('').length > 42);
29
+ let plotWidth = $state(0);
30
+ const pageTickLabels = $derived(dayBuckets
31
+ ? trend.buckets.map(bucket => new Intl.DateTimeFormat($locale ?? undefined, { month: 'numeric', day: 'numeric' }).format(bucket.startMs))
32
+ : timeAxisLabels(trend.buckets.map(bucket => bucket.startMs), $locale ?? undefined));
33
+ const tickStep = $derived(trend.buckets.length <= 7 && plotWidth / trend.buckets.length >= 40 ? 1
34
+ : Math.max(1, Math.ceil((trend.buckets.length - 1) / Math.max(1, Math.floor(plotWidth / (dayBuckets ? 56 : 110)) - 1))));
35
+ const showTick = (index: number) => index === 0 || index === trend.buckets.length - 1
36
+ || index % tickStep === 0 && index <= trend.buckets.length - 1 - tickStep;
22
37
  const tones = ['bg-nexus-500', 'bg-sky-400', 'bg-emerald-400', 'bg-amber-400', 'bg-red-400'] as const;
23
38
  // Match the original Tailwind palette, including carbon-500 and zinc-500.
24
39
  const palette = ['#f97316', '#38bdf8', '#34d399', '#fbbf24', '#f87171'];
@@ -38,14 +53,21 @@
38
53
  x: { stacked: true, display: false, offset: true },
39
54
  y: { stacked: true, display: false, min: 0, max: Math.max(1, trend.maxTokens) },
40
55
  },
41
- plugins: { legend: { display: false }, tooltip: { enabled: false } },
56
+ plugins: { legend: { display: false }, tooltip: { enabled: false, external: renderTooltip } },
42
57
  });
43
58
  const chartPlugins: Plugin<'bar'>[] = [{
44
59
  id: 'token-stats-bar-style',
45
60
  beforeUpdate(chart) {
46
- // Fill each time slot, keeping the same 12px gutter at every viewport width.
47
- const width = Math.max(0, chart.width / Math.max(1, chart.data.labels?.length ?? 0) - 12);
48
- for (const dataset of chart.data.datasets) dataset.barThickness = width;
61
+ // Dashboard bars retain their fixed gutter; page bars stay centered and capped.
62
+ const slotWidth = chart.width / Math.max(1, chart.data.labels?.length ?? 0);
63
+ const gap = pageChart ? Math.min(12, slotWidth / 4) : 12;
64
+ const width = Math.max(0, slotWidth - gap);
65
+ for (const dataset of chart.data.datasets) dataset.barThickness = pageChart ? Math.min(40, width) : width;
66
+ // A refresh can remove a model or bucket that was active in the previous snapshot.
67
+ if (chart.tooltip?.getActiveElements().some(({ datasetIndex, index }) =>
68
+ datasetIndex >= chart.data.datasets.length || index >= (chart.data.labels?.length ?? 0))) {
69
+ chart.tooltip.setActiveElements([], { x: 0, y: 0 });
70
+ }
49
71
  },
50
72
  }];
51
73
  let selectedBucketStart: number | null = $state(null);
@@ -70,10 +92,16 @@
70
92
  }).format(startMs);
71
93
  }
72
94
  function bucketDescription(bucket: TimeBucket): string {
73
- return `${timeLabel(bucket.startMs, true)} · ${bucket.details.length
74
- ? `${display(bucket.total)} Token · ${bucket.details.map(part => `${modelLabel(part.id)} ${display(part.tokens)}`).join(' · ')}`
95
+ return `${bucketLabel(bucket.startMs)} · ${bucket.details.length
96
+ ? `${fullCount(bucket.total)} Token · ${bucket.details.map(part => `${modelLabel(part.id)} ${fullCount(part.tokens)}`).join(' · ')}`
75
97
  : t('ui.noCountedTokens')}`;
76
98
  }
99
+ function bucketLabel(startMs: number): string {
100
+ if (!stats.bucketStarts) return timeLabel(startMs, true);
101
+ const index = stats.bucketStarts.indexOf(startMs);
102
+ const endMs = stats.bucketStarts[index + 1] ?? stats.bucketEndMs ?? stats.asOfMs ?? refreshedAt;
103
+ return `${timeLabel(startMs, true)} – ${timeLabel(endMs, true)}`;
104
+ }
77
105
 
78
106
  function cancelClose() {
79
107
  clearTimeout(closeTimer);
@@ -81,14 +109,31 @@
81
109
  }
82
110
  function closeBucket() {
83
111
  cancelClose();
112
+ chart?.tooltip?.setActiveElements([], { x: 0, y: 0 });
84
113
  selectedBucketStart = null;
85
114
  tooltipAnchor = null;
86
115
  }
87
- function showBucket(startMs: number, element: HTMLElement, mode: 'hover' | 'focus' | 'touch') {
116
+ function renderTooltip({ tooltip }: { tooltip: TooltipModel<'bar'> }) {
117
+ const index = tooltip.opacity === 0 ? undefined : tooltip.dataPoints[0]?.dataIndex;
118
+ const bucket = index === undefined ? undefined : trend.buckets[index];
119
+ const anchor = index === undefined ? undefined : bucketTriggers?.children[index];
120
+ if (!bucket || !(anchor instanceof HTMLElement)) {
121
+ selectedBucketStart = null;
122
+ tooltipAnchor = null;
123
+ return;
124
+ }
125
+ tooltipBucket = bucket;
126
+ tooltipAnchor = anchor;
127
+ selectedBucketStart = bucket.startMs;
128
+ }
129
+ function showBucket(startMs: number, mode: 'hover' | 'focus' | 'touch') {
88
130
  cancelClose();
89
131
  tooltipMode = mode;
90
- tooltipAnchor = element;
91
- selectedBucketStart = startMs;
132
+ const index = trend.buckets.findIndex(bucket => bucket.startMs === startMs);
133
+ if (!chart?.tooltip || index < 0) return;
134
+ // Use Chart.js's programmatic API for hover, keyboard and touch alike, including empty buckets.
135
+ const position = chart.getDatasetMeta(0).data[index]?.getCenterPoint() ?? { x: 0, y: 0 };
136
+ chart.tooltip.setActiveElements(chart.data.datasets.map((_, datasetIndex) => ({ datasetIndex, index })), position);
92
137
  }
93
138
  function leaveBucket() {
94
139
  if (tooltipMode !== 'hover') return;
@@ -97,48 +142,70 @@
97
142
  closeTimer = setTimeout(closeBucket, 100);
98
143
  }
99
144
 
100
- onMount(() => () => cancelClose());
145
+ $effect(() => {
146
+ // Clear the old chart selection before changing ranges or destroying its accessible triggers.
147
+ void range;
148
+ return closeBucket;
149
+ });
101
150
  </script>
102
151
 
103
- <div class="relative flex min-h-0 min-w-0 h-full flex-col overflow-hidden" data-testid="token-stats-time-chart">
104
- <div class="relative min-h-0 flex-1 border-b border-carbon-500">
105
- <div class="pointer-events-none absolute -left-[6px] -right-[6px] inset-y-0 z-10">
106
- <Bar {data} {options} plugins={chartPlugins} aria-hidden="true" />
107
- </div>
108
- <div class="flex h-full items-end gap-[12px]" role="group" aria-label={t('ui.timeChart')}>
109
- {#each trend.buckets as bucket, index (bucket.startMs)}
110
- <button type="button" aria-pressed={selectedBucketStart === bucket.startMs}
111
- aria-label={bucketDescription(bucket)} aria-describedby={selectedBucketStart === bucket.startMs ? tooltipId : undefined} data-token-stats-bucket-trigger
112
- class="flex min-w-0 flex-1 self-stretch flex-col justify-end transition-colors hover:bg-carbon-700/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-nexus-500 {selectedBucketStart === bucket.startMs ? 'bg-carbon-700/40' : ''}"
113
- onpointerenter={(event) => {
114
- if (event.pointerType !== 'touch') showBucket(bucket.startMs, event.currentTarget, 'hover');
115
- }}
116
- onpointerleave={leaveBucket}
117
- onfocus={(event) => showBucket(bucket.startMs, event.currentTarget, 'focus')}
118
- onpointerdown={(event) => {
119
- touchBucketWasOpen = event.pointerType === 'touch' && selectedBucketStart === bucket.startMs;
120
- }}
121
- onclick={(event) => {
122
- if ('pointerType' in event && event.pointerType === 'touch') {
123
- if (touchBucketWasOpen) closeBucket();
124
- else showBucket(bucket.startMs, event.currentTarget, 'touch');
125
- } else showBucket(bucket.startMs, event.currentTarget, event.detail === 0 ? 'focus' : 'hover');
126
- }}
127
- onblur={(event) => {
128
- if (selectedBucketStart === bucket.startMs && !(event.relatedTarget instanceof HTMLElement && event.currentTarget.parentElement?.contains(event.relatedTarget))) closeBucket();
129
- }}
130
- onkeydown={(event) => {
131
- if (event.key === 'Escape') closeBucket();
132
- if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
133
- event.preventDefault();
134
- const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? trend.buckets.length - 1
135
- : index + (event.key === 'ArrowRight' ? 1 : -1);
136
- const next = event.currentTarget.parentElement?.children[nextIndex] as HTMLElement | undefined;
137
- next?.focus();
138
- }
139
- }}>
140
- </button>
141
- {/each}
152
+ <div class="relative flex min-h-0 min-w-0 h-full flex-col overflow-hidden" data-testid="token-stats-time-chart" data-bucket-ms={stats.bucketMs} data-presentation={presentation}>
153
+ <div class="min-h-0 min-w-0 flex-1 flex flex-col {pageChart ? 'overflow-hidden' : ''}" bind:clientWidth={plotWidth} data-testid="token-stats-chart-viewport">
154
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col">
155
+ <div class="relative min-h-0 flex-1 border-b border-carbon-500">
156
+ <div class="pointer-events-none absolute inset-y-0 z-10 {pageChart ? 'inset-x-0' : '-left-[6px] -right-[6px]'}">
157
+ <Bar {data} {options} plugins={chartPlugins} bind:chart aria-hidden="true" />
158
+ </div>
159
+ <div class="h-full {pageChart ? 'grid' : 'flex items-end gap-[12px]'}" style:grid-template-columns={pageChart ? `repeat(${trend.buckets.length}, minmax(0, 1fr))` : undefined}
160
+ bind:this={bucketTriggers} role="group" aria-label={t('ui.timeChart')}>
161
+ {#each trend.buckets as bucket, index (bucket.startMs)}
162
+ <button type="button" aria-pressed={selectedBucketStart === bucket.startMs}
163
+ aria-label={bucketDescription(bucket)} aria-describedby={selectedBucketStart === bucket.startMs ? tooltipId : undefined} data-token-stats-bucket-trigger
164
+ class="flex min-w-0 flex-1 self-stretch flex-col justify-end transition-colors hover:bg-carbon-700/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-nexus-500 {selectedBucketStart === bucket.startMs ? 'bg-carbon-700/40' : ''}"
165
+ onpointerenter={(event) => {
166
+ if (event.pointerType !== 'touch') showBucket(bucket.startMs, 'hover');
167
+ }}
168
+ onpointerleave={leaveBucket}
169
+ onfocus={() => showBucket(bucket.startMs, 'focus')}
170
+ onpointerdown={(event) => {
171
+ touchBucketWasOpen = event.pointerType === 'touch' && selectedBucketStart === bucket.startMs;
172
+ }}
173
+ onclick={(event) => {
174
+ if ('pointerType' in event && event.pointerType === 'touch') {
175
+ if (touchBucketWasOpen) closeBucket();
176
+ else showBucket(bucket.startMs, 'touch');
177
+ } else showBucket(bucket.startMs, event.detail === 0 ? 'focus' : 'hover');
178
+ }}
179
+ onblur={(event) => {
180
+ if (selectedBucketStart === bucket.startMs && !(event.relatedTarget instanceof HTMLElement && event.currentTarget.parentElement?.contains(event.relatedTarget))) closeBucket();
181
+ }}
182
+ onkeydown={(event) => {
183
+ if (event.key === 'Escape') closeBucket();
184
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
185
+ event.preventDefault();
186
+ const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? trend.buckets.length - 1
187
+ : index + (event.key === 'ArrowRight' ? 1 : -1);
188
+ const next = event.currentTarget.parentElement?.children[nextIndex] as HTMLElement | undefined;
189
+ next?.focus();
190
+ }
191
+ }}>
192
+ </button>
193
+ {/each}
194
+ </div>
195
+ </div>
196
+ {#if pageChart}
197
+ <div class="mt-2 grid shrink-0 font-mono text-xs leading-tight text-zinc-400" style:grid-template-columns={`repeat(${trend.buckets.length}, minmax(0, 1fr))`} aria-hidden="true" data-testid="token-stats-page-axis">
198
+ {#each trend.buckets as bucket, index (bucket.startMs)}
199
+ <span class="w-max whitespace-nowrap {trend.buckets.length > 1 && index === 0 ? 'justify-self-start' : trend.buckets.length > 1 && index === trend.buckets.length - 1 ? 'justify-self-end' : 'justify-self-center'}">{showTick(index) ? pageTickLabels[index] : ''}</span>
200
+ {/each}
201
+ </div>
202
+ {:else}
203
+ <div class="mt-1 flex shrink-0 items-start justify-between gap-1 font-mono text-[10px] leading-tight text-zinc-400" aria-hidden="true">
204
+ <span class="shrink-0 whitespace-nowrap">{axisTicks[0]}</span>
205
+ <span class="min-w-0 whitespace-nowrap text-center {denseAxis ? 'hidden sm:inline' : ''}">{axisTicks[1]}</span>
206
+ <span class="shrink-0 whitespace-nowrap text-right">{axisTicks[2]}</span>
207
+ </div>
208
+ {/if}
142
209
  </div>
143
210
  </div>
144
211
  <Tooltip.SharedContent anchor={tooltipAnchor} open={selectedBucket !== undefined} id={tooltipId}
@@ -146,9 +213,9 @@
146
213
  class="w-72 font-mono text-[11px]"
147
214
  data-testid="token-stats-bucket-detail">
148
215
  {#if tooltipBucket}
149
- <div class="border-b border-carbon-600 pb-2 text-zinc-400">{timeLabel(tooltipBucket.startMs, true)}</div>
216
+ <div class="border-b border-carbon-600 pb-2 text-zinc-400">{bucketLabel(tooltipBucket.startMs)}</div>
150
217
  <div class="mt-2 flex items-baseline justify-between gap-3">
151
- <span>Token</span><strong class="font-display text-sm text-zinc-50">{display(tooltipBucket.total)}</strong>
218
+ <span>Token</span><strong class="font-display text-sm text-zinc-50" title={fullCount(tooltipBucket.total)} aria-label={fullCount(tooltipBucket.total)}>{display(tooltipBucket.total)}</strong>
152
219
  </div>
153
220
  <div class="mt-2 space-y-1.5">
154
221
  {#each tooltipBucket.details as part (part.id)}
@@ -157,7 +224,7 @@
157
224
  <span class="mt-1 h-2 w-2 shrink-0 {modelTone(part.id)}" aria-hidden="true"></span>
158
225
  <span class="break-all">{modelLabel(part.id)}</span>
159
226
  </span>
160
- <strong class="shrink-0 font-normal text-zinc-50">{display(part.tokens)}</strong>
227
+ <strong class="shrink-0 font-normal text-zinc-50" title={fullCount(part.tokens)} aria-label={fullCount(part.tokens)}>{display(part.tokens)}</strong>
161
228
  </div>
162
229
  {:else}
163
230
  <span class="text-zinc-400">{t('ui.noCountedTokens')}</span>
@@ -165,16 +232,11 @@
165
232
  </div>
166
233
  {/if}
167
234
  </Tooltip.SharedContent>
168
- <div class="mt-1 flex shrink-0 items-start justify-between gap-1 font-mono text-[10px] leading-tight text-zinc-400" aria-hidden="true">
169
- <span class="shrink-0 whitespace-nowrap">{axisTicks[0]}</span>
170
- <span class="min-w-0 whitespace-nowrap text-center {denseAxis ? 'hidden sm:inline' : ''}">{axisTicks[1]}</span>
171
- <span class="shrink-0 whitespace-nowrap text-right">{axisTicks[2]}</span>
172
- </div>
173
- <div class="mt-2 flex shrink-0 flex-wrap gap-x-3 gap-y-1 font-mono text-[10px] text-zinc-300">
235
+ <div class="mt-2 flex shrink-0 flex-wrap gap-x-3 gap-y-1 font-mono {pageChart ? 'text-xs' : 'text-[10px]'} text-zinc-300" data-testid="token-stats-chart-legend">
174
236
  {#each trend.models as id (id)}
175
237
  <span class="inline-flex min-w-0 items-center gap-1" title={modelLabel(id)}>
176
238
  <span class="h-2 w-2 shrink-0 {modelTone(id)}" aria-hidden="true"></span>
177
- <span class="max-w-28 truncate">{modelLabel(id)}</span>
239
+ <span class={pageChart ? 'min-w-0 break-all' : 'max-w-28 truncate'}>{modelLabel(id)}</span>
178
240
  </span>
179
241
  {/each}
180
242
  </div>