dsh-tokenlens 1.1.0 → 1.2.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.
package/client.js CHANGED
@@ -208,6 +208,12 @@ window.__ModuleLoader__.load({
208
208
  statsChartPeak: '峰值 {value}({day})',
209
209
  statsChartBarTitle: '{date}: {tokens}',
210
210
  statsChartReadout: '{date} · {value}({share}%)',
211
+ statsChartBack: '← 返回日趋势',
212
+ statsChartBackHint: '返回每日趋势图',
213
+ statsChartHourTitle: '按小时 · {date}',
214
+ statsChartHourBarTitle: '{date} {hour} · {tokens}',
215
+ statsChartHourEmpty: '该日没有小时级数据',
216
+ statsWindowLabel: '明细统计窗口',
211
217
  statsByHarness: '按 Harness',
212
218
  statsHarnessUnavailable: '不可用',
213
219
  statsHarnessRetryHint: '检查本机 Harness 数据源后点刷新重试',
@@ -429,6 +435,12 @@ window.__ModuleLoader__.load({
429
435
  statsChartPeak: 'Peak {value} ({day})',
430
436
  statsChartBarTitle: '{date}: {tokens}',
431
437
  statsChartReadout: '{date} · {value} ({share}%)',
438
+ statsChartBack: '← Back to daily',
439
+ statsChartBackHint: 'Back to the daily trend chart',
440
+ statsChartHourTitle: 'By hour · {date}',
441
+ statsChartHourBarTitle: '{date} {hour} · {tokens}',
442
+ statsChartHourEmpty: 'No hourly data for this day',
443
+ statsWindowLabel: 'Details window',
432
444
  statsByHarness: 'By harness',
433
445
  statsHarnessUnavailable: 'Unavailable',
434
446
  statsHarnessRetryHint: 'Check the harness data source, then click Refresh to retry',
@@ -1238,6 +1250,17 @@ window.__ModuleLoader__.load({
1238
1250
  }
1239
1251
  }
1240
1252
 
1253
+ /**
1254
+ * The local 0..23 hour histogram of one daily entry (v1.2.0), or null when
1255
+ * the wire omits it (old Hosts). Anything but a length-24 array is treated
1256
+ * as unsupported; a non-finite / negative slot degrades to 0 so hostile data
1257
+ * can never poison the chart.
1258
+ */
1259
+ function normalizeHourHistogram(raw) {
1260
+ if (!Array.isArray(raw) || raw.length !== 24) return null
1261
+ return raw.map((value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0))
1262
+ }
1263
+
1241
1264
  /** One provider attribution row (stats v3 aggregates DSH + ZCode per provider). */
1242
1265
  function normalizeProviderRow(entry) {
1243
1266
  const providerId = stringOrEmpty(entry.providerId)
@@ -1358,6 +1381,8 @@ window.__ModuleLoader__.load({
1358
1381
  : []
1359
1382
  // v0.24.0: the Host reports a per-day token series (ascending, <=120 days)
1360
1383
  // for the chart mode. Every field is normalized defensively.
1384
+ // v1.2.0: each day (and each provider split of that day) may also carry
1385
+ // the local 0..23 hour histogram used by the chart drill-down.
1361
1386
  const daily = Array.isArray(raw.daily)
1362
1387
  ? raw.daily
1363
1388
  .filter((entry) => entry !== null && typeof entry === 'object')
@@ -1369,10 +1394,18 @@ window.__ModuleLoader__.load({
1369
1394
  const dailyProviders = {}
1370
1395
  for (const [providerId, bucketRaw] of Object.entries(providerRaw)) {
1371
1396
  if (providerId === '' || bucketRaw === null || typeof bucketRaw !== 'object') continue
1372
- dailyProviders[providerId] = normalizeBucket(bucketRaw)
1397
+ dailyProviders[providerId] = {
1398
+ ...normalizeBucket(bucketRaw),
1399
+ hours: normalizeHourHistogram(bucketRaw.hours),
1400
+ }
1373
1401
  }
1374
1402
  const day = stringOrEmpty(entry.day).trim()
1375
- return { day, total: normalizeOptionalBucket(entry.total), providers: dailyProviders }
1403
+ return {
1404
+ day,
1405
+ total: normalizeOptionalBucket(entry.total),
1406
+ hours: normalizeHourHistogram(entry.hours),
1407
+ providers: dailyProviders,
1408
+ }
1376
1409
  })
1377
1410
  .filter((entry) => entry.day !== '')
1378
1411
  .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0))
@@ -1706,6 +1739,12 @@ window.__ModuleLoader__.load({
1706
1739
  // v0.25.0 chart hover: the nearest bar index under the pointer, or null.
1707
1740
  // Reset on mouse leave and whenever the scope/view/provider changes.
1708
1741
  chartHover: null,
1742
+ // v1.2.0 chart drill-down: the clicked day whose 0..23 hour histogram
1743
+ // is charted instead of the daily trend, or null (daily trend).
1744
+ chartDay: null,
1745
+ // v1.2.0 明细 donut window: 'today' | 'last7Days' | 'second' | 'total'.
1746
+ // Presentation state only (never persisted to the plugin settings).
1747
+ detailsWindow: 'total',
1709
1748
  // Provider management page (v0.9.0).
1710
1749
  view: 'usage', // 'usage' | 'management'
1711
1750
  management: defaultManagementState(),
@@ -1787,6 +1826,8 @@ window.__ModuleLoader__.load({
1787
1826
  statsScope: settings.defaultScope,
1788
1827
  showDetails: showDetailsFor(settings.detailsExpanded),
1789
1828
  chartHover: null,
1829
+ chartDay: null,
1830
+ detailsWindow: 'total',
1790
1831
  }
1791
1832
  }
1792
1833
 
@@ -2857,6 +2898,19 @@ window.__ModuleLoader__.load({
2857
2898
  .dsh-tokenlens-chart-summary>span{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
2858
2899
  .dsh-tokenlens-chart-empty{box-sizing:border-box;min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border:1px dashed var(--dsw-alias-border-subtle,rgba(127,127,127,.28));border-radius:8px;padding:10px;text-align:center;min-width:0}
2859
2900
  .dsh-tokenlens-chart-empty-hint{opacity:.8;font-size:11px}
2901
+ /* Hourly drill-down of the daily chart (v1.2.0): clickable/focusable day bars,
2902
+ a selected-day ring, and the back + title row above the hour bars. */
2903
+ .dsh-tokenlens-chart-bar-wrap-clickable{cursor:pointer}
2904
+ .dsh-tokenlens-chart-bar-wrap-clickable:focus-visible{outline:2px solid #2e9e5b;outline-offset:1px;border-radius:3px}
2905
+ .dsh-tokenlens-chart-bar-wrap[data-chart-selected=true] .dsh-tokenlens-chart-bar{background:#1d6b3d;box-shadow:0 0 0 1px rgba(64,200,119,.9)}
2906
+ .dsh-tokenlens-chart-drilldown{gap:4px}
2907
+ .dsh-tokenlens-chart-drill-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
2908
+ .dsh-tokenlens-chart-back{font:inherit;font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;padding:1px 8px;border:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.24));border-radius:999px;background:0 0;color:var(--dsw-alias-label-tertiary);cursor:pointer;flex:none}
2909
+ .dsh-tokenlens-chart-back:hover{color:var(--dsw-alias-label-secondary)}
2910
+ .dsh-tokenlens-chart-back:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5))}
2911
+ .dsh-tokenlens-chart-drill-title{color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
2912
+ /* 明细 donut window switch (v1.2.0): one pill row under the details title. */
2913
+ .dsh-tokenlens-details-window{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
2860
2914
  .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75}
2861
2915
  .dsh-tokenlens-cards{gap:6px;flex-wrap:wrap;display:flex}
2862
2916
  .dsh-tokenlens-cards-caption{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;margin-bottom:-3px}
@@ -3555,7 +3609,7 @@ window.__ModuleLoader__.load({
3555
3609
  'data-scope': scope,
3556
3610
  'data-active': effective === scope ? 'true' : 'false',
3557
3611
  disabled: stats === null,
3558
- onClick: () => store.set({ statsScope: scope, chartHover: null }),
3612
+ onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null }),
3559
3613
  },
3560
3614
  label,
3561
3615
  )
@@ -3581,7 +3635,7 @@ window.__ModuleLoader__.load({
3581
3635
  'data-active': active === view ? 'true' : 'false',
3582
3636
  'aria-pressed': active === view ? 'true' : 'false',
3583
3637
  onClick: () => {
3584
- if (active !== view) store.set({ statsView: view, chartHover: null })
3638
+ if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null })
3585
3639
  },
3586
3640
  },
3587
3641
  Icon(iconName, 12),
@@ -3601,37 +3655,88 @@ window.__ModuleLoader__.load({
3601
3655
  }
3602
3656
 
3603
3657
  /**
3604
- * The daily series of the chart (v0.24.0).
3658
+ * The daily provider ids the chart's provider scope matches (v0.24.0).
3659
+ *
3660
+ * Both the stats row id and the session-selected id are matched, in exact and
3661
+ * canonical form, so `deepseek-official` and `deepseek` find the same bucket.
3662
+ */
3663
+ function chartDailyProviderIds(ctx) {
3664
+ const wanted = []
3665
+ if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
3666
+ wanted.push(ctx.currentRow.providerId)
3667
+ }
3668
+ if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
3669
+ wanted.push(ctx.selectedProviderId)
3670
+ }
3671
+ return { wanted, canonicalWanted: new Set(wanted.map((id) => canonicalProviderId(id))) }
3672
+ }
3673
+
3674
+ /** The daily provider bucket of one day in provider scope, or null. */
3675
+ function chartDailyProviderBucket(ctx, entry) {
3676
+ const { wanted, canonicalWanted } = chartDailyProviderIds(ctx)
3677
+ for (const [key, bucket] of Object.entries(entry.providers)) {
3678
+ if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket ?? null
3679
+ }
3680
+ return null
3681
+ }
3682
+
3683
+ /** The 24-hour histogram of one chart point, or null when unsupported. */
3684
+ function chartHoursOfPoint(point) {
3685
+ return point !== null && point !== undefined && Array.isArray(point.hours) && point.hours.length === 24
3686
+ ? point.hours
3687
+ : null
3688
+ }
3689
+
3690
+ /** The `HH:00` label of one local hour of the drill-down chart (v1.2.0). */
3691
+ function chartHourLabel(hour) {
3692
+ return `${String(hour).padStart(2, '0')}:00`
3693
+ }
3694
+
3695
+ /** Enter / Space activate a focusable chart bar (v1.2.0). */
3696
+ function isChartActivationKey(event) {
3697
+ const key = typeof event?.key === 'string' ? event.key : ''
3698
+ return key === 'Enter' || key === ' ' || key === 'Spacebar'
3699
+ }
3700
+
3701
+ /**
3702
+ * Toggle the chart drill-down (v1.2.0): clicking a day charts its 0..23 hour
3703
+ * histogram; clicking the SAME day again returns to the daily trend.
3704
+ */
3705
+ function activateChartDay(day) {
3706
+ if (typeof day !== 'string' || day === '') return
3707
+ const next = store.get().chartDay === day ? null : day
3708
+ store.set({ chartDay: next, chartHover: null })
3709
+ }
3710
+
3711
+ /** Leave the hourly drill-down and chart the daily trend again (v1.2.0). */
3712
+ function clearChartDay() {
3713
+ if (store.get().chartDay !== null) store.set({ chartDay: null, chartHover: null })
3714
+ }
3715
+
3716
+ /**
3717
+ * The daily series of the chart (v0.24.0; v1.2.0 adds the day histograms).
3605
3718
  *
3606
3719
  * Provider scope reads the selected provider's daily bucket and limits the
3607
3720
  * window to the provider's effective day when it is subscription-backed
3608
3721
  * (fallback: the last 30 days). The all-harness scope uses the day totals of
3609
- * the last 30 days.
3722
+ * the last 30 days. Every point also carries its 0..23 hour histogram
3723
+ * (`hours`, or null on old Hosts) so the chart can drill into one day.
3610
3724
  */
3611
3725
  function chartSeriesOf(ctx) {
3612
3726
  const stats = ctx.stats
3613
3727
  if (stats === null) return []
3614
3728
  const daily = stats.daily
3615
3729
  if (ctx.scope === 'all') {
3616
- return daily.slice(-30).map((entry) => ({ day: entry.day, total: entry.total?.total ?? 0 }))
3617
- }
3618
- // Provider scope: match the daily provider buckets by the stats row id and
3619
- // the session-selected id (both exact and canonical forms).
3620
- const wanted = []
3621
- if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
3622
- wanted.push(ctx.currentRow.providerId)
3623
- }
3624
- if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
3625
- wanted.push(ctx.selectedProviderId)
3626
- }
3627
- const canonicalWanted = new Set(wanted.map((id) => canonicalProviderId(id)))
3628
- const valueOf = (entry) => {
3629
- for (const [key, bucket] of Object.entries(entry.providers)) {
3630
- if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket?.total ?? 0
3631
- }
3632
- return 0
3730
+ return daily.slice(-30).map((entry) => ({
3731
+ day: entry.day,
3732
+ total: entry.total?.total ?? 0,
3733
+ hours: entry.hours ?? null,
3734
+ }))
3633
3735
  }
3634
- const series = daily.map((entry) => ({ day: entry.day, total: valueOf(entry) }))
3736
+ const series = daily.map((entry) => {
3737
+ const bucket = chartDailyProviderBucket(ctx, entry)
3738
+ return { day: entry.day, total: bucket?.total ?? 0, hours: bucket?.hours ?? null }
3739
+ })
3635
3740
  const effectiveDay = ctx.currentRow !== null ? ctx.currentRow.effectiveDay : ''
3636
3741
  if (ctx.subscribed && typeof effectiveDay === 'string' && effectiveDay !== '') {
3637
3742
  return series.filter((point) => point.day >= effectiveDay)
@@ -3710,7 +3815,6 @@ window.__ModuleLoader__.load({
3710
3815
  if (store.get().chartHover !== next) store.set({ chartHover: next })
3711
3816
  }
3712
3817
 
3713
- /** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
3714
3818
  /** The active today bucket of the current stats scope (v0.27.2). */
3715
3819
  function todayBucketOf(ctx) {
3716
3820
  return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
@@ -3727,37 +3831,25 @@ window.__ModuleLoader__.load({
3727
3831
  return ctx.providerScoped ? ctx.currentRow?.last7Days ?? null : ctx.stats?.last7Days ?? null
3728
3832
  }
3729
3833
 
3730
- function buildDailyChart(state, ctx, t) {
3731
- const points = chartSeriesOf(ctx)
3732
- const total = points.reduce((sum, point) => sum + point.total, 0)
3733
- if (points.length === 0 || total === 0) {
3734
- return h(
3735
- 'div',
3736
- { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true', 'data-chart-empty-series': 'true' },
3737
- h(
3738
- 'div',
3739
- { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-empty': 'true' },
3740
- h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartEmpty')),
3741
- h(
3742
- 'span',
3743
- { key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
3744
- t('statsChartEmptyHint'),
3745
- ),
3746
- ),
3747
- )
3748
- }
3834
+ /**
3835
+ * The shared pure-CSS bar plot of the chart modes (v0.25.0; v1.2.0 reuses
3836
+ * it for the hourly drill-down): Y-axis ticks, gridlines, bars, the X-axis
3837
+ * labels, the crosshair and the live pointer readout. Pure DOM, no deps.
3838
+ *
3839
+ * `spec.bar(point, index, meta)` returns the wrapper element of one bar
3840
+ * (`meta.max` / `meta.hovered` drive its height and highlight),
3841
+ * `spec.xLabel(point, index)` returns `{text, attrs}` for the X axis, and
3842
+ * `spec.readout(hovered, share)` returns the live readout text.
3843
+ */
3844
+ function buildChartPlot(state, points, spec) {
3749
3845
  const max = Math.max(...points.map((point) => point.total), 1)
3750
- let peak = points[0]
3751
- for (const point of points) {
3752
- if (point.total > peak.total) peak = point
3753
- }
3846
+ const total = points.reduce((sum, point) => sum + point.total, 0)
3754
3847
  const hoverIndex =
3755
3848
  Number.isInteger(state.chartHover) && state.chartHover >= 0 && state.chartHover < points.length
3756
3849
  ? state.chartHover
3757
3850
  : null
3758
3851
  const hovered = hoverIndex === null ? null : points[hoverIndex]
3759
- const hoverShare =
3760
- hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
3852
+ const hoverShare = hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
3761
3853
  const handleMove = (event) => {
3762
3854
  const index = chartIndexFromEvent(event, points.length)
3763
3855
  if (index !== null) setChartHover(index)
@@ -3783,32 +3875,10 @@ window.__ModuleLoader__.load({
3783
3875
  tick.text,
3784
3876
  ),
3785
3877
  )
3786
- const bars = points.map((point, index) => {
3787
- const height = point.total > 0 ? Math.max(2, Math.round((point.total / max) * 1000) / 10) : 0
3788
- const isHovered = index === hoverIndex
3789
- return h(
3790
- 'div',
3791
- {
3792
- key: point.day,
3793
- className: 'dsh-tokenlens-chart-bar-wrap',
3794
- 'data-chart-day': point.day,
3795
- 'data-chart-total': String(point.total),
3796
- 'data-chart-hover': isHovered ? 'true' : 'false',
3797
- title: t('statsChartBarTitle', {
3798
- date: point.day,
3799
- tokens: formatTokensPrecise(point.total),
3800
- }),
3801
- },
3802
- h('div', {
3803
- className: 'dsh-tokenlens-chart-bar',
3804
- 'data-empty': point.total > 0 ? 'false' : 'true',
3805
- 'data-height': String(height),
3806
- style: { height: `${height}%` },
3807
- }),
3808
- )
3809
- })
3878
+ const bars = points.map((point, index) => spec.bar(point, index, { max, hovered: index === hoverIndex }))
3810
3879
  const xLabels = chartXLabelIndices(points.length).map((index) => {
3811
3880
  const point = points[index]
3881
+ const label = spec.xLabel(point, index)
3812
3882
  const first = index === 0
3813
3883
  const last = index === points.length - 1
3814
3884
  const style = first
@@ -3819,12 +3889,12 @@ window.__ModuleLoader__.load({
3819
3889
  return h(
3820
3890
  'span',
3821
3891
  {
3822
- key: `x-${point.day}`,
3892
+ key: `x-${index}`,
3823
3893
  className: 'dsh-tokenlens-chart-x-label',
3824
- 'data-chart-axis-day': point.day,
3894
+ ...(label.attrs === null || label.attrs === undefined ? {} : label.attrs),
3825
3895
  style,
3826
3896
  },
3827
- chartDayLabel(point.day),
3897
+ label.text,
3828
3898
  )
3829
3899
  })
3830
3900
  const crosshair =
@@ -3857,37 +3927,249 @@ window.__ModuleLoader__.load({
3857
3927
  role: 'status',
3858
3928
  style: readoutStyle,
3859
3929
  },
3860
- t('statsChartReadout', {
3861
- date: hovered.day,
3862
- value: formatTokensPrecise(hovered.total),
3863
- share: String(hoverShare),
3864
- }),
3930
+ spec.readout(hovered, hoverShare),
3865
3931
  )
3866
3932
  return h(
3867
3933
  'div',
3868
- { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true' },
3934
+ { key: 'body', className: 'dsh-tokenlens-chart-body' },
3935
+ h('div', { key: 'y-axis', className: 'dsh-tokenlens-chart-yaxis', 'aria-hidden': 'true' }, yTicks),
3869
3936
  h(
3870
3937
  'div',
3871
- { key: 'body', className: 'dsh-tokenlens-chart-body' },
3872
- h('div', { key: 'y-axis', className: 'dsh-tokenlens-chart-yaxis', 'aria-hidden': 'true' }, yTicks),
3938
+ {
3939
+ key: 'plot',
3940
+ className: 'dsh-tokenlens-chart-plot',
3941
+ 'data-chart-plot': 'true',
3942
+ onMouseMove: handleMove,
3943
+ onMouseLeave: handleLeave,
3944
+ onPointerMove: handleMove,
3945
+ onPointerLeave: handleLeave,
3946
+ },
3947
+ h('div', { key: 'grid', className: 'dsh-tokenlens-chart-grid', 'aria-hidden': 'true' }, gridLines),
3948
+ h('div', { key: 'bars', className: 'dsh-tokenlens-chart-bars' }, bars),
3949
+ crosshair,
3950
+ readout,
3951
+ h('div', { key: 'xaxis', className: 'dsh-tokenlens-chart-xaxis', 'aria-hidden': 'true' }, xLabels),
3952
+ ),
3953
+ )
3954
+ }
3955
+
3956
+ /**
3957
+ * One bar of the daily trend chart (v0.25.0). A day whose entry carries the
3958
+ * 0..23 hour histogram is ALSO a real button (v1.2.0): focusable, Enter /
3959
+ * Space activate it, `aria-pressed` marks the drilled day. A day without
3960
+ * hours is not drillable and keeps the old non-interactive bar.
3961
+ */
3962
+ function buildDailyChartBar(state, t, point, meta) {
3963
+ const height = point.total > 0 ? Math.max(2, Math.round((point.total / meta.max) * 1000) / 10) : 0
3964
+ const drillable = chartHoursOfPoint(point) !== null
3965
+ const selected = drillable && state.chartDay === point.day
3966
+ const attrs = {
3967
+ key: point.day,
3968
+ className: drillable
3969
+ ? 'dsh-tokenlens-chart-bar-wrap dsh-tokenlens-chart-bar-wrap-clickable'
3970
+ : 'dsh-tokenlens-chart-bar-wrap',
3971
+ 'data-chart-day': point.day,
3972
+ 'data-chart-total': String(point.total),
3973
+ 'data-chart-hover': meta.hovered ? 'true' : 'false',
3974
+ 'data-chart-drillable': drillable ? 'true' : 'false',
3975
+ 'data-chart-selected': selected ? 'true' : 'false',
3976
+ title: t('statsChartBarTitle', {
3977
+ date: point.day,
3978
+ tokens: formatTokensPrecise(point.total),
3979
+ }),
3980
+ }
3981
+ if (drillable) {
3982
+ attrs.role = 'button'
3983
+ attrs.tabIndex = 0
3984
+ attrs['aria-pressed'] = selected ? 'true' : 'false'
3985
+ attrs['aria-label'] = t('statsChartBarTitle', {
3986
+ date: point.day,
3987
+ tokens: formatTokensPrecise(point.total),
3988
+ })
3989
+ attrs.onClick = () => activateChartDay(point.day)
3990
+ attrs.onKeyDown = (event) => {
3991
+ if (!isChartActivationKey(event)) return
3992
+ if (typeof event?.preventDefault === 'function') event.preventDefault()
3993
+ activateChartDay(point.day)
3994
+ }
3995
+ }
3996
+ return h(
3997
+ 'div',
3998
+ attrs,
3999
+ h('div', {
4000
+ className: 'dsh-tokenlens-chart-bar',
4001
+ 'data-empty': point.total > 0 ? 'false' : 'true',
4002
+ 'data-height': String(height),
4003
+ style: { height: `${height}%` },
4004
+ }),
4005
+ )
4006
+ }
4007
+
4008
+ /**
4009
+ * The drilled day view (v1.2.0): one day's 00:00–23:00 token bars, with a
4010
+ * back button to the daily trend. It reuses the shared plot (axes, pointer
4011
+ * readout, tooltips) and the existing summary formatters.
4012
+ */
4013
+ function buildHourChart(state, point, hours, t) {
4014
+ const dayLabel = chartDayLabel(point.day)
4015
+ const hourPoints = hours.map((value, hour) => ({ hour, total: value }))
4016
+ const total = hourPoints.reduce((sum, hourPoint) => sum + hourPoint.total, 0)
4017
+ const head = h(
4018
+ 'div',
4019
+ { key: 'drill-head', className: 'dsh-tokenlens-chart-drill-head', 'data-chart-drill-head': 'true' },
4020
+ h(
4021
+ 'button',
4022
+ {
4023
+ key: 'back',
4024
+ type: 'button',
4025
+ className: 'dsh-tokenlens-chart-back',
4026
+ 'data-chart-back': point.day,
4027
+ title: t('statsChartBackHint'),
4028
+ 'aria-label': t('statsChartBack'),
4029
+ onClick: () => clearChartDay(),
4030
+ },
4031
+ t('statsChartBack'),
4032
+ ),
4033
+ h(
4034
+ 'span',
4035
+ { key: 'title', className: 'dsh-tokenlens-chart-drill-title', 'data-chart-hour-title': point.day },
4036
+ t('statsChartHourTitle', { date: point.day }),
4037
+ ),
4038
+ )
4039
+ const containerAttrs = {
4040
+ key: 'chart',
4041
+ className: 'dsh-tokenlens-chart dsh-tokenlens-chart-drilldown',
4042
+ 'data-chart': 'true',
4043
+ 'data-chart-drilldown': 'true',
4044
+ 'data-chart-drill-day': point.day,
4045
+ }
4046
+ if (total <= 0) {
4047
+ // A hosted histogram that is all zeros carries no data: say so instead of
4048
+ // inventing an empty 24-bar chart (old Hosts never get this far: their
4049
+ // bars are not drillable at all).
4050
+ return h(
4051
+ 'div',
4052
+ containerAttrs,
4053
+ head,
3873
4054
  h(
4055
+ 'div',
4056
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-hour-empty': 'true' },
4057
+ h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartHourEmpty')),
4058
+ ),
4059
+ )
4060
+ }
4061
+ let peak = hourPoints[0]
4062
+ for (const hourPoint of hourPoints) {
4063
+ if (hourPoint.total > peak.total) peak = hourPoint
4064
+ }
4065
+ const body = buildChartPlot(state, hourPoints, {
4066
+ bar: (hourPoint, index, meta) => {
4067
+ const height = hourPoint.total > 0 ? Math.max(2, Math.round((hourPoint.total / meta.max) * 1000) / 10) : 0
4068
+ return h(
3874
4069
  'div',
3875
4070
  {
3876
- key: 'plot',
3877
- className: 'dsh-tokenlens-chart-plot',
3878
- 'data-chart-plot': 'true',
3879
- onMouseMove: handleMove,
3880
- onMouseLeave: handleLeave,
3881
- onPointerMove: handleMove,
3882
- onPointerLeave: handleLeave,
4071
+ key: `hour-${hourPoint.hour}`,
4072
+ className: 'dsh-tokenlens-chart-bar-wrap',
4073
+ 'data-chart-hour': String(hourPoint.hour),
4074
+ 'data-chart-total': String(hourPoint.total),
4075
+ 'data-chart-hover': meta.hovered ? 'true' : 'false',
4076
+ title: t('statsChartHourBarTitle', {
4077
+ date: dayLabel,
4078
+ hour: chartHourLabel(hourPoint.hour),
4079
+ tokens: formatTokensPrecise(hourPoint.total),
4080
+ }),
3883
4081
  },
3884
- h('div', { key: 'grid', className: 'dsh-tokenlens-chart-grid', 'aria-hidden': 'true' }, gridLines),
3885
- h('div', { key: 'bars', className: 'dsh-tokenlens-chart-bars' }, bars),
3886
- crosshair,
3887
- readout,
3888
- h('div', { key: 'xaxis', className: 'dsh-tokenlens-chart-xaxis', 'aria-hidden': 'true' }, xLabels),
4082
+ h('div', {
4083
+ className: 'dsh-tokenlens-chart-bar',
4084
+ 'data-empty': hourPoint.total > 0 ? 'false' : 'true',
4085
+ 'data-height': String(height),
4086
+ style: { height: `${height}%` },
4087
+ }),
4088
+ )
4089
+ },
4090
+ xLabel: (hourPoint) => ({
4091
+ text: chartHourLabel(hourPoint.hour),
4092
+ attrs: { 'data-chart-axis-hour': String(hourPoint.hour) },
4093
+ }),
4094
+ readout: (hovered, share) =>
4095
+ t('statsChartReadout', {
4096
+ date: `${dayLabel} ${chartHourLabel(hovered.hour)}`,
4097
+ value: formatTokensPrecise(hovered.total),
4098
+ share: String(share),
4099
+ }),
4100
+ })
4101
+ return h(
4102
+ 'div',
4103
+ containerAttrs,
4104
+ head,
4105
+ body,
4106
+ h(
4107
+ 'div',
4108
+ { key: 'summary', className: 'dsh-tokenlens-chart-summary' },
4109
+ h(
4110
+ 'span',
4111
+ { key: 'total', 'data-chart-summary': 'hour-total' },
4112
+ t('statsChartTotal', { value: formatTokensPrecise(total) }),
4113
+ ),
4114
+ h(
4115
+ 'span',
4116
+ { key: 'peak', 'data-chart-summary': 'hour-peak' },
4117
+ t('statsChartPeak', { value: formatTokensPrecise(peak.total), day: chartHourLabel(peak.hour) }),
3889
4118
  ),
3890
4119
  ),
4120
+ )
4121
+ }
4122
+
4123
+ /** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
4124
+ function buildDailyChart(state, ctx, t) {
4125
+ const points = chartSeriesOf(ctx)
4126
+ const total = points.reduce((sum, point) => sum + point.total, 0)
4127
+ if (points.length === 0 || total === 0) {
4128
+ return h(
4129
+ 'div',
4130
+ { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true', 'data-chart-empty-series': 'true' },
4131
+ h(
4132
+ 'div',
4133
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-empty': 'true' },
4134
+ h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartEmpty')),
4135
+ h(
4136
+ 'span',
4137
+ { key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
4138
+ t('statsChartEmptyHint'),
4139
+ ),
4140
+ ),
4141
+ )
4142
+ }
4143
+ // v1.2.0: a drilled day replaces the daily trend with its 24-hour
4144
+ // histogram. A stale selection (day gone / no hours) silently falls back to
4145
+ // the daily trend instead of charting a fabricated hour series.
4146
+ const drillPoint =
4147
+ typeof state.chartDay === 'string' && state.chartDay !== ''
4148
+ ? (points.find((point) => point.day === state.chartDay) ?? null)
4149
+ : null
4150
+ const drillHours = drillPoint === null ? null : chartHoursOfPoint(drillPoint)
4151
+ if (drillPoint !== null && drillHours !== null) return buildHourChart(state, drillPoint, drillHours, t)
4152
+ let peak = points[0]
4153
+ for (const point of points) {
4154
+ if (point.total > peak.total) peak = point
4155
+ }
4156
+ const body = buildChartPlot(state, points, {
4157
+ bar: (point, index, meta) => buildDailyChartBar(state, t, point, meta),
4158
+ xLabel: (point) => ({
4159
+ text: chartDayLabel(point.day),
4160
+ attrs: { 'data-chart-axis-day': point.day },
4161
+ }),
4162
+ readout: (hovered, share) =>
4163
+ t('statsChartReadout', {
4164
+ date: hovered.day,
4165
+ value: formatTokensPrecise(hovered.total),
4166
+ share: String(share),
4167
+ }),
4168
+ })
4169
+ return h(
4170
+ 'div',
4171
+ { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true' },
4172
+ body,
3891
4173
  h(
3892
4174
  'div',
3893
4175
  { key: 'summary', className: 'dsh-tokenlens-chart-summary' },
@@ -4086,6 +4368,83 @@ window.__ModuleLoader__.load({
4086
4368
  return t('statsLast30Days')
4087
4369
  }
4088
4370
 
4371
+ /**
4372
+ * The 明细 donut windows in display order (v1.2.0): 今日 → 最近7天 →
4373
+ * 最近X天/最近30天 → 总计. Old Hosts omit the 7-day / second windows, so their
4374
+ * pills are hidden (never disabled ghosts of a window that cannot exist).
4375
+ */
4376
+ function detailsWindowChoices(ctx, t) {
4377
+ const choices = [{ key: 'today', label: t('statsTodayCol') }]
4378
+ if (ctx.hasWeek) choices.push({ key: 'last7Days', label: t('statsLast7Days') })
4379
+ if (ctx.hasMonth) choices.push({ key: 'second', label: detailsSecondLabel(ctx, t) })
4380
+ choices.push({ key: 'total', label: t('statsAllTimeCol') })
4381
+ return choices
4382
+ }
4383
+
4384
+ /**
4385
+ * The active 明细 donut window (v1.2.0): the stored choice while that window
4386
+ * still exists, else 总计. The state lives in the store next to statsView /
4387
+ * statsScope and is never persisted to the plugin settings.
4388
+ */
4389
+ function detailsWindowOf(state, ctx) {
4390
+ const key = typeof state.detailsWindow === 'string' ? state.detailsWindow : 'total'
4391
+ if (key === 'today') return 'today'
4392
+ if (key === 'last7Days') return ctx.hasWeek ? 'last7Days' : 'total'
4393
+ if (key === 'second') return ctx.hasMonth ? 'second' : 'total'
4394
+ return 'total'
4395
+ }
4396
+
4397
+ /**
4398
+ * The bucket of one 明细 row for a donut window (v1.2.0). Harness/provider
4399
+ * rows carry the four stats windows directly; model rows use the caller's
4400
+ * second-window accessor (`secondModelOf`) for the effective-window column.
4401
+ */
4402
+ function detailsWindowBucketOf(windowKey, buckets, secondBucketOf) {
4403
+ if (buckets === null || buckets === undefined) return null
4404
+ if (windowKey === 'today') return buckets.today ?? null
4405
+ if (windowKey === 'last7Days') return buckets.last7Days ?? null
4406
+ if (windowKey === 'second') {
4407
+ return typeof secondBucketOf === 'function' ? secondBucketOf(buckets) : buckets.last30Days ?? null
4408
+ }
4409
+ return buckets.allTime ?? null
4410
+ }
4411
+
4412
+ /** The token total of one 明细 row for a donut window (v1.2.0). */
4413
+ function detailsWindowTotalOf(windowKey, buckets, secondBucketOf) {
4414
+ const bucket = detailsWindowBucketOf(windowKey, buckets, secondBucketOf)
4415
+ return bucket === null ? 0 : bucket.total ?? 0
4416
+ }
4417
+
4418
+ /** The window pill row under the 明细 donut title (v1.2.0). */
4419
+ function buildDetailsWindowSwitch(state, ctx, t) {
4420
+ const active = detailsWindowOf(state, ctx)
4421
+ const option = (key, label) =>
4422
+ h(
4423
+ 'button',
4424
+ {
4425
+ key,
4426
+ type: 'button',
4427
+ className: 'dsh-tokenlens-scope-option',
4428
+ 'data-details-window': key,
4429
+ 'data-active': active === key ? 'true' : 'false',
4430
+ 'aria-pressed': active === key ? 'true' : 'false',
4431
+ onClick: () => {
4432
+ if (active !== key) store.set({ detailsWindow: key })
4433
+ },
4434
+ },
4435
+ label,
4436
+ )
4437
+ return h(
4438
+ 'div',
4439
+ { key: 'details-window', className: 'dsh-tokenlens-details-window', 'data-details-window-switch': 'true' },
4440
+ h(
4441
+ 'div',
4442
+ { key: 'scope', className: 'dsh-tokenlens-scope', role: 'group', 'aria-label': t('statsWindowLabel') },
4443
+ detailsWindowChoices(ctx, t).map((choice) => option(choice.key, choice.label)),
4444
+ ),
4445
+ )
4446
+ }
4447
+
4089
4448
  /**
4090
4449
  * The compact 明细 table columns of one group (v0.30.2). Harness/provider
4091
4450
  * rows start at 今日; the all-harness top-model table starts at the
@@ -4758,10 +5117,18 @@ window.__ModuleLoader__.load({
4758
5117
  )
4759
5118
  }
4760
5119
 
4761
- /** The 明细 column in chart mode: donut share charts + ranked legends. */
5120
+ /**
5121
+ * The 明细 column in chart mode: donut share charts + ranked legends.
5122
+ *
5123
+ * v1.2.0: a 今日 / 最近7天 / 最近X天 / 总计 window switch under the title picks
5124
+ * which stats window the three donuts aggregate (default 总计). The pills of
5125
+ * windows an old Host does not report are hidden; the state lives in the store
5126
+ * (never persisted to the plugin settings).
5127
+ */
4762
5128
  function buildStatsDetailsCharts(state, ctx, t) {
4763
- const { stats, hasMonth } = ctx
5129
+ const { stats } = ctx
4764
5130
  if (stats === null) return null
5131
+ const windowKey = detailsWindowOf(state, ctx)
4765
5132
  const children = [
4766
5133
  h(
4767
5134
  'div',
@@ -4773,17 +5140,18 @@ window.__ModuleLoader__.load({
4773
5140
  h('span', { key: 'label' }, t('statsDetailsTitle')),
4774
5141
  ),
4775
5142
  ),
5143
+ buildDetailsWindowSwitch(state, ctx, t),
4776
5144
  ]
5145
+ const groups = []
4777
5146
  const harnessRows = scopedHarnessRows(ctx).map((row) => ({
4778
5147
  label: row.label,
4779
- value: ctx.secondBucketOf(row.buckets)?.total ?? 0,
5148
+ value: detailsWindowTotalOf(windowKey, row.buckets, ctx.secondBucketOf),
4780
5149
  }))
4781
- const groups = []
4782
5150
  const harnessChart = buildBreakdownDonutGroup('harness-chart', t('statsByHarness'), 'list', harnessRows, t)
4783
5151
  if (harnessChart !== null) groups.push(harnessChart)
4784
5152
  const providerRows = stats.providers.map((provider) => ({
4785
5153
  label: provider.label,
4786
- value: ctx.secondBucketOf(provider)?.total ?? 0,
5154
+ value: detailsWindowTotalOf(windowKey, provider, ctx.secondBucketOf),
4787
5155
  }))
4788
5156
  const providerChart = buildBreakdownDonutGroup('provider-chart', t('statsByProvider'), 'server', providerRows, t)
4789
5157
  if (providerChart !== null) groups.push(providerChart)
@@ -4792,18 +5160,25 @@ window.__ModuleLoader__.load({
4792
5160
  // models from other providers silently disappear from the donut (v0.27.1).
4793
5161
  const modelRows = stats.models.map((model) => ({
4794
5162
  label: model.modelId,
4795
- value: ctx.secondModelOf(model)?.total ?? 0,
5163
+ value: detailsWindowTotalOf(windowKey, model, ctx.secondModelOf),
4796
5164
  }))
4797
5165
  const modelChart = buildBreakdownDonutGroup('model-chart', t('statsTopModels'), 'cube', modelRows, t)
4798
5166
  if (modelChart !== null) groups.push(modelChart)
4799
5167
  if (groups.length > 0) {
4800
5168
  children.push(h('div', { key: 'charts', className: 'dsh-tokenlens-details-charts-grid' }, groups))
4801
- }
4802
- if (children.length === 1) {
5169
+ } else {
5170
+ // An all-zero window renders no donut at all: keep the readable hint.
4803
5171
  children.push(h('div', { key: 'empty', className: 'dsh-tokenlens-hint' }, t('statsEmpty')))
4804
5172
  }
4805
- void hasMonth
4806
- return h('div', { key: 'details', className: 'dsh-tokenlens-section dsh-tokenlens-details' }, children)
5173
+ return h(
5174
+ 'div',
5175
+ {
5176
+ key: 'details',
5177
+ className: 'dsh-tokenlens-section dsh-tokenlens-details',
5178
+ 'data-details-window': windowKey,
5179
+ },
5180
+ children,
5181
+ )
4807
5182
  }
4808
5183
 
4809
5184
  function buildStatsDetails(state, ctx, t) {