dsh-token-total 1.0.2 → 1.0.4

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.
@@ -73,6 +73,9 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
73
73
  const [stats, setStats] = useState<TokenTotalRange | null>(null)
74
74
  const [loading, setLoading] = useState(true)
75
75
  const [error, setError] = useState('')
76
+ // When the last successful fetch happened — the "as of" stamp shows a
77
+ // concrete clock time, not just the range's date.
78
+ const [loadedAt, setLoadedAt] = useState<number | null>(null)
76
79
  const generationRef = useRef(0)
77
80
  // The panel bounds the chart tooltips: a tip must never leave the panel
78
81
  // even when the hovered cell sits near its edge.
@@ -116,6 +119,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
116
119
  const res = await fetchRange(req)
117
120
  if (generationRef.current !== generation) return // stale response
118
121
  setStats(res)
122
+ setLoadedAt(Date.now())
119
123
  // One trigger chain for both surfaces. The heatmap window does not
120
124
  // follow `range`, so binding its fetch to the mount effect alone meant a
121
125
  // single failed request left the grid blank until the user pressed
@@ -233,15 +237,22 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
233
237
  />
234
238
  </div>
235
239
  )}
236
- <Button
237
- size="sm"
238
- variant="outline"
239
- className={css.refresh}
240
- onClick={() => { void load() }}
241
- disabled={loading}
242
- >
243
- {t('refresh')}
244
- </Button>
240
+ <div className={css.toolbarEnd}>
241
+ {stats && (
242
+ <span className={css.asOf}>
243
+ {t('asOf')} {asOfStamp(stats.to, loadedAt)}
244
+ </span>
245
+ )}
246
+ <Button
247
+ size="sm"
248
+ variant="outline"
249
+ className={css.refresh}
250
+ onClick={() => { void load() }}
251
+ disabled={loading}
252
+ >
253
+ {t('refresh')}
254
+ </Button>
255
+ </div>
245
256
  </div>
246
257
 
247
258
  {error && <div className={css.errorBanner}>{error}</div>}
@@ -252,8 +263,13 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
252
263
  {stats && (
253
264
  <>
254
265
  <StatCards stats={stats} t={t} />
255
- <Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
256
- <DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
266
+ {/* Heatmap and the token trend share ONE row —one card each, left
267
+ and right (热力图左 / 趋势右). The duo grid stretches both cards
268
+ to a common height and folds to one column on a narrow pane. */}
269
+ <div className={css.duo}>
270
+ <Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
271
+ <DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
272
+ </div>
257
273
  <UsageBreakdown
258
274
  models={stats.models}
259
275
  providers={stats.providers}
@@ -261,12 +277,8 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
261
277
  providerNames={stats.providerNames}
262
278
  t={t}
263
279
  panelRef={panelRef}
280
+ colorForModel={colorForModel}
264
281
  />
265
- {stats.to && (
266
- <div className={css.foot}>
267
- {t('asOf')} {stats.to}
268
- </div>
269
- )}
270
282
  </>
271
283
  )}
272
284
  {!error && stats && isEmptyRange(stats) && (
@@ -278,6 +290,15 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
278
290
 
279
291
  // ── Section 2+3: numeric cards ────────────────────────────────────────────
280
292
 
293
+ /** "As of" stamp for a loaded range: the fetch clock time when the range
294
+ * reaches today (the data is live up to that refresh), else the day's end —
295
+ * the wire carries only dates, and a past day's data is complete. */
296
+ function asOfStamp(to: string, fetchedAt: number | null): string {
297
+ if (to !== localDay(0)) return `${to} 23:59`
298
+ const d = new Date(fetchedAt ?? Date.now())
299
+ return `${to} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
300
+ }
301
+
281
302
  /** A range is empty only when NOTHING was recorded in it: a pure-cache day
282
303
  * (zero input/output but real cache hits) or a failed-call day (requests
283
304
  * but no tokens) both carry real usage and must not read as "no data". */
@@ -286,28 +307,31 @@ function isEmptyRange(stats: TokenTotalRange): boolean {
286
307
  }
287
308
 
288
309
  function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
289
- const cards: Array<{ icon: typeof Coins; label: string; value: string; hint?: string; sub?: string }> = [
310
+ // Each card wears one accent hue (tone 1..6 → --dsw-tone-1..6): the accent
311
+ // drives the left bar, the icon badge tint, and the corner wash, so the
312
+ // overview row reads in colour instead of one gray register.
313
+ const cards: Array<{ icon: typeof Coins; label: string; value: string; hint?: string; sub?: string; tone: number }> = [
290
314
  // 总tokens —the provider-inclusive headline (uncached input + output +
291
315
  // cache reads/writes): the number a provider dashboard reports. Compact
292
316
  // K/M/B on the headline, the exact count underneath.
293
- { icon: Coins, label: t('tokens'), value: formatCompact(stats.tokens), hint: t('tokensHint'), sub: `${formatTokens(stats.tokens)} ${t('tokensUnit')}` },
317
+ { icon: Coins, label: t('tokens'), value: formatCompact(stats.tokens), hint: t('tokensHint'), sub: `${formatTokens(stats.tokens)} ${t('tokensUnit')}`, tone: 1 },
294
318
  // 输入 tokens —uncached prompt tokens the provider billed as new input.
295
- { icon: ArrowDownToLine, label: t('inputTokens'), value: formatCompact(stats.inputTokens), sub: `${formatTokens(stats.inputTokens)} ${t('tokensUnit')}` },
319
+ { icon: ArrowDownToLine, label: t('inputTokens'), value: formatCompact(stats.inputTokens), sub: `${formatTokens(stats.inputTokens)} ${t('tokensUnit')}`, tone: 2 },
296
320
  // 输出 tokens —completion tokens.
297
- { icon: ArrowUpFromLine, label: t('outputTokens'), value: formatCompact(stats.outputTokens), sub: `${formatTokens(stats.outputTokens)} ${t('tokensUnit')}` },
321
+ { icon: ArrowUpFromLine, label: t('outputTokens'), value: formatCompact(stats.outputTokens), sub: `${formatTokens(stats.outputTokens)} ${t('tokensUnit')}`, tone: 3 },
298
322
  // 缓存 tokens —cached prompt tokens (cache reads + cache writes).
299
- { icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}` },
323
+ { icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}`, tone: 4 },
300
324
  // 缓存命中率 —cached share of all prompt tokens in the range.
301
- { icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}` },
325
+ { icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}`, tone: 5 },
302
326
  // 请求数量 —provider calls (tokens or not). The failed share sits
303
327
  // underneath, and only when there were any: a healthy range stays a
304
328
  // single line. Failed calls are a SUBSET of requests, never additive.
305
- { icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
329
+ { icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined, tone: 6 },
306
330
  ]
307
331
  return (
308
332
  <div className={css.cards}>
309
333
  {cards.map((c) => (
310
- <div className={css.card} data-dsh-usage-glass="card" key={c.label} title={c.hint}>
334
+ <div className={clsx(css.card, css[`cardTone${c.tone}`])} data-dsh-usage-glass="card" key={c.label} title={c.hint}>
311
335
  <div className={css.cardHead}>
312
336
  <c.icon className={css.cardIcon} size={14} strokeWidth={2} aria-hidden="true" />
313
337
  <span className={css.cardLabel}>{c.label}</span>
@@ -315,7 +339,9 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
315
339
  {c.sub !== undefined ? (
316
340
  <div className={clsx(css.cardValue, css.cardModelLines)}>
317
341
  <FitText text={c.value} className={css.cardValue} maxSize={22} />
318
- <div className={css.cardSub}>{c.sub}</div>
342
+ {/* The exact-count line is the longest text in the card: fit it
343
+ too (down to 8px) instead of letting it clip. */}
344
+ <FitText text={c.sub} className={css.cardSub} maxSize={11} minSize={8} />
319
345
  </div>
320
346
  ) : (
321
347
  <FitText text={c.value} className={css.cardValue} maxSize={22} />
@@ -327,8 +353,9 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
327
353
  }
328
354
 
329
355
  // FitText renders `text` on a single line, shrinking the font until it fits
330
- // the card width (long token numbers never overflow or wrap).
331
- function FitText({ text, className, maxSize }: { text: string; className?: string; maxSize: number }) {
356
+ // the card width (long token numbers never overflow or wrap). `minSize` is
357
+ // the floor: secondary lines may shrink further than headline values.
358
+ function FitText({ text, className, maxSize, minSize = 11 }: { text: string; className?: string; maxSize: number; minSize?: number }) {
332
359
  const ref = useRef<HTMLDivElement>(null)
333
360
  const [size, setSize] = useState(maxSize)
334
361
 
@@ -338,7 +365,7 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
338
365
  const fit = () => {
339
366
  let s = maxSize
340
367
  el.style.fontSize = `${s}px`
341
- while (el.scrollWidth > el.clientWidth + 1 && s > 11) {
368
+ while (el.scrollWidth > el.clientWidth + 1 && s > minSize) {
342
369
  s -= 0.5
343
370
  el.style.fontSize = `${s}px`
344
371
  }
@@ -348,7 +375,7 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
348
375
  const ro = new ResizeObserver(fit)
349
376
  ro.observe(el)
350
377
  return () => ro.disconnect()
351
- }, [text, maxSize])
378
+ }, [text, maxSize, minSize])
352
379
 
353
380
  return (
354
381
  <div ref={ref} className={className} style={{ fontSize: size }}>
@@ -359,13 +386,26 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
359
386
 
360
387
  // ── Section 4: GitHub-style activity heatmap ──────────────────────────────
361
388
 
362
- // Cell/gap ratio follows reasonix (base 13 / gap 3): a 1px gap against a
363
- // ~20px grown cell read as tiles touching edge to edge. The gap also equals
364
- // the legend's own 3px swatch spacing, and the fixed rx matches the legend
365
- // swatch border-radius, so the key renders at exactly the chart's geometry.
366
- const HEAT_BASE = 14 // cell size at which column trimming starts
367
- const HEAT_GAP = 3 // breathing gap between cells; mirrors the legend swatch gap
368
- const HEAT_RX = 3 // cell corner radius; matches the legend swatch (css .heatCell)
389
+ // Cell/gap ratio follows the reference chart (a GitHub-style green heatmap):
390
+ // roughly a third of the cell in gap, so the tiles read as separate rounded
391
+ // squares instead of a wash. The gap also equals the legend's own swatch
392
+ // spacing, and the fixed rx matches the legend swatch border-radius, so the
393
+ // key renders at exactly the chart's geometry.
394
+ const HEAT_BASE = 12 // cell size at which column trimming starts
395
+ const HEAT_GAP = 4 // breathing gap between cells; mirrors the legend swatch gap
396
+ const HEAT_RX = 2 // cell corner radius; matches the legend swatch (css .heatCell)
397
+
398
+ // ── heatmap axis gutters ──────────────────────────────────────────────────
399
+ // The chart now carries a weekday column on the left and month labels along
400
+ // the bottom (matching the GitHub-style reference). Both are drawn INSIDE the
401
+ // same SVG so a label sits on the exact cell track it names; the geometry
402
+ // solver only ever sees the width left over after these gutters are reserved.
403
+ const HEAT_AXIS_LEFT = 30 // px reserved left of the first cell column (weekday labels)
404
+ const HEAT_AXIS_BOTTOM = 18 // px reserved under the last row (month labels)
405
+
406
+ /** 0 = Monday … 6 = Sunday: row order of the heatmap, and the label keys. */
407
+ const DOW_KEYS = ['dow.mon', 'dow.tue', 'dow.wed', 'dow.thu', 'dow.fri', 'dow.sat', 'dow.sun'] as const
408
+ const MONTH_KEYS = ['month.jan', 'month.feb', 'month.mar', 'month.apr', 'month.may', 'month.jun', 'month.jul', 'month.aug', 'month.sep', 'month.oct', 'month.nov', 'month.dec'] as const
369
409
 
370
410
  /** One heatmap frame's geometry. */
371
411
  export interface HeatGeometry {
@@ -439,7 +479,9 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
439
479
  const el = wrapRef.current
440
480
  if (!el) return
441
481
  const update = () => {
442
- const avail = Math.max(1, el.clientWidth - 2)
482
+ // Reserve the weekday gutter so the cells solve against the space they
483
+ // actually get; the SVG width below re-adds it.
484
+ const avail = Math.max(1, el.clientWidth - 2 - HEAT_AXIS_LEFT)
443
485
  const next = heatGeometry(avail, (indexOfDay(from) + 1) % 7)
444
486
  // Only commit when the geometry actually changed —the heatmap SVG
445
487
  // width follows `geom.size`, and committing an identical value on
@@ -463,12 +505,38 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
463
505
  // partial first week, so the SVG width follows it directly.
464
506
  const weeks = geom.totalWeeks
465
507
 
508
+ // Month labels: name a column only where its month differs from the
509
+ // previous column's, so a run of weeks in one month gets a single label
510
+ // sitting over its first week (the GitHub axis convention). The reading is
511
+ // taken from the column's own top cell (row 0), which is the earliest day
512
+ // that column holds.
513
+ // The window is a whole number of weeks, so it can wrap back into the month
514
+ // it started in; that trailing label would sit in the last column and be
515
+ // clipped by the wrap's overflow. Drop it — it repeats the first label.
516
+ const monthLabels: Array<{ col: number; month: number }> = []
517
+ let lastMonth = -1
518
+ for (let i = 0; i < days.length; i++) {
519
+ const col = Math.floor((i + startOffset) / 7)
520
+ if ((i + startOffset) % 7 !== 0) continue // only the column's first day
521
+ const m = new Date(days[i]! + 'T00:00:00').getMonth()
522
+ if (m !== lastMonth) {
523
+ monthLabels.push({ col, month: m })
524
+ lastMonth = m
525
+ }
526
+ }
527
+ while (monthLabels.length > 1 && monthLabels[0]!.month === monthLabels[monthLabels.length - 1]!.month) monthLabels.pop()
528
+
529
+ const cellW = geom.size + HEAT_GAP
530
+ const svgW = HEAT_AXIS_LEFT + weeks * cellW + HEAT_GAP
531
+ const svgH = rows * cellW + HEAT_GAP + HEAT_AXIS_BOTTOM
532
+
466
533
  return (
467
534
  <section className={css.section}>
468
535
  <div className={css.sectionHead}>
469
536
  <h3 className={css.sectionTitle}>{t('heatmap')}</h3>
470
537
  <div className={css.heatLegend}>
471
538
  <span>{t('heatLess')}</span>
539
+ <i className={clsx(css.heatCell, css.heatLevel0)} style={{ width: geom.size, height: geom.size }} />
472
540
  <i className={clsx(css.heatCell, css.heatLevel1)} style={{ width: geom.size, height: geom.size }} />
473
541
  <i className={clsx(css.heatCell, css.heatLevel2)} style={{ width: geom.size, height: geom.size }} />
474
542
  <i className={clsx(css.heatCell, css.heatLevel3)} style={{ width: geom.size, height: geom.size }} />
@@ -478,15 +546,28 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
478
546
  </div>
479
547
  </div>
480
548
  <div className={css.heatWrap} ref={wrapRef}>
481
- <svg className={css.heatmap} width={weeks * (geom.size + HEAT_GAP) + HEAT_GAP} height={rows * (geom.size + HEAT_GAP) + HEAT_GAP} role="img" aria-label={t('heatmap')}>
549
+ <svg className={css.heatmap} width={svgW} height={svgH} role="img" aria-label={t('heatmap')}>
550
+ {/* Weekday gutter: every row is named Mon…Sun so a bar reads by day. */}
551
+ {DOW_KEYS.map((key, row) => (
552
+ <text
553
+ key={key}
554
+ className={css.heatAxis}
555
+ x={HEAT_AXIS_LEFT - 8}
556
+ y={HEAT_GAP + row * cellW + geom.size / 2}
557
+ textAnchor="end"
558
+ dominantBaseline="central"
559
+ >
560
+ {t(key)}
561
+ </text>
562
+ ))}
482
563
  {days.map((day, i) => {
483
564
  const col = Math.floor((i + startOffset) / 7)
484
565
  const row = (i + startOffset) % 7
485
566
  const rec = byDay.get(day)
486
567
  const tokens = rec?.total ?? 0
487
568
  const level = tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * 4)
488
- const x = HEAT_GAP + col * (geom.size + HEAT_GAP)
489
- const y = HEAT_GAP + row * (geom.size + HEAT_GAP)
569
+ const x = HEAT_AXIS_LEFT + HEAT_GAP + col * cellW
570
+ const y = HEAT_GAP + row * cellW
490
571
  return (
491
572
  <rect
492
573
  key={day}
@@ -505,6 +586,19 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
505
586
  />
506
587
  )
507
588
  })}
589
+ {/* Month gutter: one label per month, over the column it starts in. */}
590
+ {monthLabels.map(({ col, month }) => (
591
+ <text
592
+ key={`${col}-${month}`}
593
+ className={css.heatAxis}
594
+ x={HEAT_AXIS_LEFT + HEAT_GAP + col * cellW}
595
+ y={rows * cellW + HEAT_GAP + 3}
596
+ textAnchor="start"
597
+ dominantBaseline="hanging"
598
+ >
599
+ {t(MONTH_KEYS[month]!)}
600
+ </text>
601
+ ))}
508
602
  </svg>
509
603
  {tip && (
510
604
  <ChartTip anchor={tip.anchor} panelRef={panelRef}>
@@ -575,10 +669,34 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
575
669
  const plotWUsed = view.avail
576
670
  const maxTotal = Math.max(1, ...visible.map((d) => d.total))
577
671
  const ticks = niceTicks(maxTotal, 4)
578
- // X labels keep at least ~46px between their columns —a fixed "every Nth
579
- // day" count collided when few days stretched across the whole plot
580
- // (7/14-day presets labelled every bar).
581
- const labelEvery = Math.max(1, Math.ceil(46 / step))
672
+ // What the X axis reads as depends on the WINDOW, not just the granularity:
673
+ // a multi-day hourly span (3天) is a DATE axis — one short date per day, at
674
+ // the day's first bucket, times would be noise across three day-clusters;
675
+ // a single-day hourly span (今日) is a CLOCK axis — the date is constant and
676
+ // would only repeat. Daily ranges stay date labels.
677
+ const dayCount = new Set(visible.map((d) => d.day.slice(0, 10))).size
678
+ const multiDayHourly = granularity === 'hour' && dayCount > 1
679
+ const axisMode: 'day' | 'hour' = multiDayHourly ? 'day' : granularity
680
+ // Label buckets: day starts on multi-day hourly, every bucket otherwise.
681
+ const labelCandidates: number[] = []
682
+ for (let i = 0; i < n; i++) {
683
+ if (!multiDayHourly || i === 0 || visible[i].day.slice(0, 10) !== visible[i - 1].day.slice(0, 10)) labelCandidates.push(i)
684
+ }
685
+ // Candidates are then thinned by POSITION, walking from the right edge: the
686
+ // rightmost candidate always keeps its label, an earlier one is kept only
687
+ // when it clears the previously kept label by the label width. (A fixed
688
+ // "every Nth" count plus a forced last label drew two labels on top of
689
+ // each other — the "18:00/23:00" overlap.) Both label shapes are ~30px.
690
+ const labelMinGap = 42
691
+ const labeledIdx = new Set<number>()
692
+ let lastLabelX = Number.POSITIVE_INFINITY
693
+ for (let k = labelCandidates.length - 1; k >= 0; k--) {
694
+ const x = padL + barHalf + labelCandidates[k] * step
695
+ if (lastLabelX - x >= labelMinGap) {
696
+ labeledIdx.add(labelCandidates[k])
697
+ lastLabelX = x
698
+ }
699
+ }
582
700
 
583
701
  const trendPoints: Array<{ x: number; y: number }> = []
584
702
  const trendPointByDay = new Map<string, { x: number; y: number }>()
@@ -603,7 +721,10 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
603
721
  <section className={css.section}>
604
722
  <div className={css.sectionHead}>
605
723
  <h3 className={css.sectionTitle}>{t(granularity === 'hour' ? 'hourlyTrend' : 'dailyTrend')}</h3>
606
- {trendCapped && <span className={css.trendNote}>{t('trendLimited').replace('{n}', String(view.trimN))}</span>}
724
+ <div className={css.headMeta}>
725
+ {trendCapped && <span className={css.trendNote}>{t('trendLimited').replace('{n}', String(view.trimN))}</span>}
726
+ <span className={css.legendItem}><i className={css.legendTrend} aria-hidden="true" />{t('cacheHitRate')}</span>
727
+ </div>
607
728
  </div>
608
729
  <div className={css.chartWrap} ref={wrapRef}>
609
730
  <svg className={css.chart} width="100%" height={H} viewBox={`0 0 ${plotWUsed} ${H}`} onMouseLeave={() => { setTip(null); setHover(null) }}>
@@ -655,8 +776,8 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
655
776
  }}
656
777
  onMouseLeave={() => setTip(null)}
657
778
  />
658
- {(i % labelEvery === 0 || i === n - 1) && (
659
- <text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, granularity)}</text>
779
+ {labeledIdx.has(i) && (
780
+ <text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, axisMode)}</text>
660
781
  )}
661
782
  </g>
662
783
  )
@@ -750,18 +871,16 @@ function providerColor(index: number): string {
750
871
  return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
751
872
  }
752
873
 
753
- /** Model hues run 1..10 then the gray Other step (mirrors colorForModel). */
754
- function modelColor(index: number): string {
755
- return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
756
- }
757
-
758
- function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
874
+ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef, colorForModel }: {
759
875
  models: ModelTokenUsage[]
760
876
  providers: ProviderTokenUsage[]
761
877
  total: number
762
878
  providerNames?: Record<string, string>
763
879
  t: Translator
764
880
  panelRef: RefObject<HTMLDivElement | null>
881
+ /** The trend's model palette (rank in `models`), so a model wears the same
882
+ * hue in the bars, the tooltips and both rankings. */
883
+ colorForModel: (model: string) => string
765
884
  }) {
766
885
  const [tip, setTip] = useState<RankTip | null>(null)
767
886
 
@@ -776,6 +895,15 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
776
895
 
777
896
  const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
778
897
 
898
+ // One provider, one hue EVERYWHERE: the palette indexes the provider
899
+ // ranking, so a provider keeps its colour as a card row AND as another
900
+ // row's breakdown child. Per-child ranks used to renumber the same
901
+ // provider differently in every row.
902
+ const providerColorOf = useCallback((provider: string): string => {
903
+ const slot = providers.findIndex((p) => p.provider === provider)
904
+ return slot < 0 ? 'var(--dsw-provider-other)' : providerColor(slot)
905
+ }, [providers])
906
+
779
907
  const modelRows = useMemo<BreakdownRow[]>(() => {
780
908
  const byName = new Map<string, {
781
909
  tokens: number
@@ -801,36 +929,36 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
801
929
  return [...byName.entries()]
802
930
  .sort((a, b) => b[1].tokens - a[1].tokens)
803
931
  .slice(0, TOP_BREAKDOWN)
804
- .map(([name, acc], i) => {
932
+ .map(([name, acc]) => {
805
933
  const split: RankChild[] = [...acc.split.entries()]
806
934
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
807
- .map(([provider, tokens], j) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColor(j) }))
935
+ .map(([provider, tokens]) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColorOf(provider) }))
808
936
  // A payload from before the split still names its sources; list them
809
937
  // without a volume rather than inventing a number.
810
938
  const known = new Set(split.map((child) => child.key))
811
939
  const named: RankChild[] = [...acc.sources]
812
940
  .filter((provider) => !known.has(provider))
813
941
  .sort()
814
- .map((provider, j) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColor(split.length + j) }))
942
+ .map((provider) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColorOf(provider) }))
815
943
  return {
816
944
  key: name,
817
945
  name,
818
946
  tokens: acc.tokens,
819
947
  cacheHit: acc.cacheHit,
820
948
  cacheMiss: acc.cacheMiss,
821
- color: modelColor(i),
949
+ color: colorForModel(name),
822
950
  breakdownLabel: t('byProvider'),
823
951
  breakdown: [...split, ...named],
824
952
  }
825
953
  })
826
- }, [models, providerLabel, t])
954
+ }, [models, providerLabel, t, colorForModel, providerColorOf])
827
955
 
828
956
  const providerRows = useMemo<BreakdownRow[]>(() => {
829
957
  return [...providers]
830
958
  .filter((p) => p.tokens > 0)
831
959
  .sort((a, b) => b.tokens - a.tokens)
832
960
  .slice(0, TOP_BREAKDOWN)
833
- .map((p, i) => {
961
+ .map((p) => {
834
962
  const label = providerLabel(p.provider)
835
963
  return {
836
964
  key: p.provider,
@@ -840,14 +968,14 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
840
968
  tokens: p.tokens,
841
969
  cacheHit: p.cacheHit,
842
970
  cacheMiss: p.cacheMiss,
843
- color: providerColor(i),
971
+ color: providerColorOf(p.provider),
844
972
  breakdownLabel: t('byModel'),
845
973
  breakdown: [...(p.modelTokens ?? [])]
846
974
  .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
847
- .map((m, j) => ({ key: m.model, name: m.model, tokens: m.tokens, color: modelColor(j) })),
975
+ .map((m) => ({ key: m.model, name: m.model, tokens: m.tokens, color: colorForModel(m.model) })),
848
976
  }
849
977
  })
850
- }, [providers, providerLabel, t])
978
+ }, [providers, providerLabel, t, colorForModel, providerColorOf])
851
979
 
852
980
  return (
853
981
  <section className={css.section}>
@@ -929,6 +1057,13 @@ function RankCard({ title, rows, total, t, onTip }: {
929
1057
  {/* The share keeps its own line: the hover detail never
930
1058
  displaces it (悬浮详情不占用百分比的位置). */}
931
1059
  <span className={css.modelProvider}>{share(r.tokens)}</span>
1060
+ {/* Share bar: the row's slice of the total, in its own colour. */}
1061
+ <div className={css.rowTrack} aria-hidden="true">
1062
+ <i
1063
+ className={css.rowFill}
1064
+ style={{ width: total > 0 ? `${Math.min(100, (r.tokens / total) * 100)}%` : '0%', background: r.color }}
1065
+ />
1066
+ </div>
932
1067
  </div>
933
1068
  <div className={css.modelValues}>
934
1069
  <span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
@@ -68,13 +68,15 @@ export function shortDay(day: string): string {
68
68
  return `${Number(m)}/${Number(d)}`
69
69
  }
70
70
 
71
- /** X-axis label for a trend bucket key. Day mode keeps the short date; hour
72
- * mode names the day at 00:00 (so a three-day span keeps its day boundaries
73
- * readable) and the hour everywhere else. */
74
- export function bucketAxisLabel(key: string, granularity: 'day' | 'hour'): string {
75
- if (granularity === 'day') return shortDay(key)
76
- const hour = key.slice(11, 13)
77
- return hour === '00' ? shortDay(key.slice(0, 10)) : `${hour}:00`
71
+ /** X-axis label for a trend bucket key. `mode` is what the axis READS AS,
72
+ * chosen by the caller from the window it covers:
73
+ * - 'hour' — a single-day hourly span (今日): the date is constant, so the
74
+ * axis shows the bare clock ("8:00");
75
+ * - 'day' — anything spanning several days (3天 hourly, any daily range):
76
+ * the axis shows the short date ("10/22"); on multi-day hourly spans one
77
+ * label marks each day. The tooltip still carries the full date + hour. */
78
+ export function bucketAxisLabel(key: string, mode: 'day' | 'hour'): string {
79
+ return mode === 'hour' ? `${Number(key.slice(11, 13))}:00` : shortDay(key)
78
80
  }
79
81
 
80
82
  /** Tooltip title for a trend bucket key: the date in day mode, date + hour
@@ -43,6 +43,25 @@ export type TokenTotalKey =
43
43
  | 'heatmap'
44
44
  | 'heatLess'
45
45
  | 'heatMore'
46
+ | 'dow.mon'
47
+ | 'dow.tue'
48
+ | 'dow.wed'
49
+ | 'dow.thu'
50
+ | 'dow.fri'
51
+ | 'dow.sat'
52
+ | 'dow.sun'
53
+ | 'month.jan'
54
+ | 'month.feb'
55
+ | 'month.mar'
56
+ | 'month.apr'
57
+ | 'month.may'
58
+ | 'month.jun'
59
+ | 'month.jul'
60
+ | 'month.aug'
61
+ | 'month.sep'
62
+ | 'month.oct'
63
+ | 'month.nov'
64
+ | 'month.dec'
46
65
  | 'dailyTrend'
47
66
  | 'hourlyTrend'
48
67
  | 'trendLimited'
@@ -112,6 +131,25 @@ export const en: Record<TokenTotalKey, string> = {
112
131
  heatmap: 'Activity heatmap',
113
132
  heatLess: 'Less',
114
133
  heatMore: 'More',
134
+ 'dow.mon': 'Mon',
135
+ 'dow.tue': 'Tue',
136
+ 'dow.wed': 'Wed',
137
+ 'dow.thu': 'Thu',
138
+ 'dow.fri': 'Fri',
139
+ 'dow.sat': 'Sat',
140
+ 'dow.sun': 'Sun',
141
+ 'month.jan': 'Jan',
142
+ 'month.feb': 'Feb',
143
+ 'month.mar': 'Mar',
144
+ 'month.apr': 'Apr',
145
+ 'month.may': 'May',
146
+ 'month.jun': 'Jun',
147
+ 'month.jul': 'Jul',
148
+ 'month.aug': 'Aug',
149
+ 'month.sep': 'Sep',
150
+ 'month.oct': 'Oct',
151
+ 'month.nov': 'Nov',
152
+ 'month.dec': 'Dec',
115
153
  dailyTrend: 'Daily token trend',
116
154
  hourlyTrend: 'Hourly token trend',
117
155
  trendLimited: 'Showing the latest {n} days',
@@ -182,6 +220,25 @@ export const zh: Record<TokenTotalKey, string> = {
182
220
  heatmap: '活跃热力图',
183
221
  heatLess: '较少',
184
222
  heatMore: '较多',
223
+ 'dow.mon': '周一',
224
+ 'dow.tue': '周二',
225
+ 'dow.wed': '周三',
226
+ 'dow.thu': '周四',
227
+ 'dow.fri': '周五',
228
+ 'dow.sat': '周六',
229
+ 'dow.sun': '周日',
230
+ 'month.jan': '1月',
231
+ 'month.feb': '2月',
232
+ 'month.mar': '3月',
233
+ 'month.apr': '4月',
234
+ 'month.may': '5月',
235
+ 'month.jun': '6月',
236
+ 'month.jul': '7月',
237
+ 'month.aug': '8月',
238
+ 'month.sep': '9月',
239
+ 'month.oct': '10月',
240
+ 'month.nov': '11月',
241
+ 'month.dec': '12月',
185
242
  dailyTrend: '按天 Token 趋势',
186
243
  hourlyTrend: '按小时 Token 趋势',
187
244
  trendLimited: '仅显示最近 {n} 天',
@@ -252,6 +309,25 @@ export const zhTW: Record<TokenTotalKey, string> = {
252
309
  heatmap: '活躍熱力圖',
253
310
  heatLess: '較少',
254
311
  heatMore: '較多',
312
+ 'dow.mon': '週一',
313
+ 'dow.tue': '週二',
314
+ 'dow.wed': '週三',
315
+ 'dow.thu': '週四',
316
+ 'dow.fri': '週五',
317
+ 'dow.sat': '週六',
318
+ 'dow.sun': '週日',
319
+ 'month.jan': '1月',
320
+ 'month.feb': '2月',
321
+ 'month.mar': '3月',
322
+ 'month.apr': '4月',
323
+ 'month.may': '5月',
324
+ 'month.jun': '6月',
325
+ 'month.jul': '7月',
326
+ 'month.aug': '8月',
327
+ 'month.sep': '9月',
328
+ 'month.oct': '10月',
329
+ 'month.nov': '11月',
330
+ 'month.dec': '12月',
255
331
  dailyTrend: '每日 Token 趨勢',
256
332
  hourlyTrend: '每小時 Token 趨勢',
257
333
  trendLimited: '僅顯示最近 {n} 天',