dsh-tokenlens 1.0.9 → 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}
@@ -3145,7 +3199,13 @@ window.__ModuleLoader__.load({
3145
3199
  return `${providerLabel} · ${parts.join(' · ')}`
3146
3200
  }
3147
3201
  if (snapshot !== null && snapshot.provider.kind === 'windows') {
3148
- return `${providerLabel} · ${windowsSummaryText(snapshot.windows, t)}`
3202
+ const summary = `${providerLabel} · ${windowsSummaryText(snapshot.windows, t)}`
3203
+ // A windows provider may also carry a wallet balance (OpenDesign): keep the
3204
+ // chip body unchanged and append the money to the hover detail only.
3205
+ if (snapshot.balance !== null) {
3206
+ return `${summary} · ${formatMoney(snapshot.balance.amount, snapshot.balance.currency)}`
3207
+ }
3208
+ return summary
3149
3209
  }
3150
3210
  return `${providerLabel} · ${t('chipNoUsage')}`
3151
3211
  }
@@ -3347,6 +3407,32 @@ window.__ModuleLoader__.load({
3347
3407
  )
3348
3408
  }
3349
3409
 
3410
+ /**
3411
+ * The wallet-balance row of a windows provider (OpenDesign).
3412
+ *
3413
+ * Some providers report allowance windows AND an independent account balance:
3414
+ * a `windows` snapshot may therefore carry a non-null `balance`. The row reuses
3415
+ * the window row's label/value layout so it reads as part of the same card, and
3416
+ * the shared narrow-screen wrapping of `.dsh-tokenlens-row-head` keeps it in view.
3417
+ */
3418
+ function buildCapacityBalanceRow(balance, t) {
3419
+ if (balance === null) return null
3420
+ return h(
3421
+ 'div',
3422
+ { key: 'balance', className: 'dsh-tokenlens-row', 'data-capacity-balance': 'true' },
3423
+ h(
3424
+ 'div',
3425
+ { className: 'dsh-tokenlens-row-head' },
3426
+ h('span', { className: 'dsh-tokenlens-row-label' }, t('balanceLabel')),
3427
+ h(
3428
+ 'span',
3429
+ { className: 'dsh-tokenlens-percent dsh-tokenlens-capacity-money' },
3430
+ formatMoney(balance.amount, balance.currency),
3431
+ ),
3432
+ ),
3433
+ )
3434
+ }
3435
+
3350
3436
  /** The provider/plan/account header line, or null when there is nothing to say. */
3351
3437
  function buildProviderHeader(snapshot, t) {
3352
3438
  if (snapshot === null) return null
@@ -3523,7 +3609,7 @@ window.__ModuleLoader__.load({
3523
3609
  'data-scope': scope,
3524
3610
  'data-active': effective === scope ? 'true' : 'false',
3525
3611
  disabled: stats === null,
3526
- onClick: () => store.set({ statsScope: scope, chartHover: null }),
3612
+ onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null }),
3527
3613
  },
3528
3614
  label,
3529
3615
  )
@@ -3549,7 +3635,7 @@ window.__ModuleLoader__.load({
3549
3635
  'data-active': active === view ? 'true' : 'false',
3550
3636
  'aria-pressed': active === view ? 'true' : 'false',
3551
3637
  onClick: () => {
3552
- if (active !== view) store.set({ statsView: view, chartHover: null })
3638
+ if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null })
3553
3639
  },
3554
3640
  },
3555
3641
  Icon(iconName, 12),
@@ -3569,37 +3655,88 @@ window.__ModuleLoader__.load({
3569
3655
  }
3570
3656
 
3571
3657
  /**
3572
- * 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).
3573
3718
  *
3574
3719
  * Provider scope reads the selected provider's daily bucket and limits the
3575
3720
  * window to the provider's effective day when it is subscription-backed
3576
3721
  * (fallback: the last 30 days). The all-harness scope uses the day totals of
3577
- * 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.
3578
3724
  */
3579
3725
  function chartSeriesOf(ctx) {
3580
3726
  const stats = ctx.stats
3581
3727
  if (stats === null) return []
3582
3728
  const daily = stats.daily
3583
3729
  if (ctx.scope === 'all') {
3584
- return daily.slice(-30).map((entry) => ({ day: entry.day, total: entry.total?.total ?? 0 }))
3585
- }
3586
- // Provider scope: match the daily provider buckets by the stats row id and
3587
- // the session-selected id (both exact and canonical forms).
3588
- const wanted = []
3589
- if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
3590
- wanted.push(ctx.currentRow.providerId)
3591
- }
3592
- if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
3593
- wanted.push(ctx.selectedProviderId)
3594
- }
3595
- const canonicalWanted = new Set(wanted.map((id) => canonicalProviderId(id)))
3596
- const valueOf = (entry) => {
3597
- for (const [key, bucket] of Object.entries(entry.providers)) {
3598
- if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket?.total ?? 0
3599
- }
3600
- 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
+ }))
3601
3735
  }
3602
- 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
+ })
3603
3740
  const effectiveDay = ctx.currentRow !== null ? ctx.currentRow.effectiveDay : ''
3604
3741
  if (ctx.subscribed && typeof effectiveDay === 'string' && effectiveDay !== '') {
3605
3742
  return series.filter((point) => point.day >= effectiveDay)
@@ -3678,7 +3815,6 @@ window.__ModuleLoader__.load({
3678
3815
  if (store.get().chartHover !== next) store.set({ chartHover: next })
3679
3816
  }
3680
3817
 
3681
- /** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
3682
3818
  /** The active today bucket of the current stats scope (v0.27.2). */
3683
3819
  function todayBucketOf(ctx) {
3684
3820
  return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
@@ -3695,37 +3831,25 @@ window.__ModuleLoader__.load({
3695
3831
  return ctx.providerScoped ? ctx.currentRow?.last7Days ?? null : ctx.stats?.last7Days ?? null
3696
3832
  }
3697
3833
 
3698
- function buildDailyChart(state, ctx, t) {
3699
- const points = chartSeriesOf(ctx)
3700
- const total = points.reduce((sum, point) => sum + point.total, 0)
3701
- if (points.length === 0 || total === 0) {
3702
- return h(
3703
- 'div',
3704
- { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true', 'data-chart-empty-series': 'true' },
3705
- h(
3706
- 'div',
3707
- { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-empty': 'true' },
3708
- h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartEmpty')),
3709
- h(
3710
- 'span',
3711
- { key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
3712
- t('statsChartEmptyHint'),
3713
- ),
3714
- ),
3715
- )
3716
- }
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) {
3717
3845
  const max = Math.max(...points.map((point) => point.total), 1)
3718
- let peak = points[0]
3719
- for (const point of points) {
3720
- if (point.total > peak.total) peak = point
3721
- }
3846
+ const total = points.reduce((sum, point) => sum + point.total, 0)
3722
3847
  const hoverIndex =
3723
3848
  Number.isInteger(state.chartHover) && state.chartHover >= 0 && state.chartHover < points.length
3724
3849
  ? state.chartHover
3725
3850
  : null
3726
3851
  const hovered = hoverIndex === null ? null : points[hoverIndex]
3727
- const hoverShare =
3728
- 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
3729
3853
  const handleMove = (event) => {
3730
3854
  const index = chartIndexFromEvent(event, points.length)
3731
3855
  if (index !== null) setChartHover(index)
@@ -3751,32 +3875,10 @@ window.__ModuleLoader__.load({
3751
3875
  tick.text,
3752
3876
  ),
3753
3877
  )
3754
- const bars = points.map((point, index) => {
3755
- const height = point.total > 0 ? Math.max(2, Math.round((point.total / max) * 1000) / 10) : 0
3756
- const isHovered = index === hoverIndex
3757
- return h(
3758
- 'div',
3759
- {
3760
- key: point.day,
3761
- className: 'dsh-tokenlens-chart-bar-wrap',
3762
- 'data-chart-day': point.day,
3763
- 'data-chart-total': String(point.total),
3764
- 'data-chart-hover': isHovered ? 'true' : 'false',
3765
- title: t('statsChartBarTitle', {
3766
- date: point.day,
3767
- tokens: formatTokensPrecise(point.total),
3768
- }),
3769
- },
3770
- h('div', {
3771
- className: 'dsh-tokenlens-chart-bar',
3772
- 'data-empty': point.total > 0 ? 'false' : 'true',
3773
- 'data-height': String(height),
3774
- style: { height: `${height}%` },
3775
- }),
3776
- )
3777
- })
3878
+ const bars = points.map((point, index) => spec.bar(point, index, { max, hovered: index === hoverIndex }))
3778
3879
  const xLabels = chartXLabelIndices(points.length).map((index) => {
3779
3880
  const point = points[index]
3881
+ const label = spec.xLabel(point, index)
3780
3882
  const first = index === 0
3781
3883
  const last = index === points.length - 1
3782
3884
  const style = first
@@ -3787,12 +3889,12 @@ window.__ModuleLoader__.load({
3787
3889
  return h(
3788
3890
  'span',
3789
3891
  {
3790
- key: `x-${point.day}`,
3892
+ key: `x-${index}`,
3791
3893
  className: 'dsh-tokenlens-chart-x-label',
3792
- 'data-chart-axis-day': point.day,
3894
+ ...(label.attrs === null || label.attrs === undefined ? {} : label.attrs),
3793
3895
  style,
3794
3896
  },
3795
- chartDayLabel(point.day),
3897
+ label.text,
3796
3898
  )
3797
3899
  })
3798
3900
  const crosshair =
@@ -3825,37 +3927,249 @@ window.__ModuleLoader__.load({
3825
3927
  role: 'status',
3826
3928
  style: readoutStyle,
3827
3929
  },
3828
- t('statsChartReadout', {
3829
- date: hovered.day,
3830
- value: formatTokensPrecise(hovered.total),
3831
- share: String(hoverShare),
3832
- }),
3930
+ spec.readout(hovered, hoverShare),
3833
3931
  )
3834
3932
  return h(
3835
3933
  'div',
3836
- { 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),
3837
3936
  h(
3838
3937
  'div',
3839
- { key: 'body', className: 'dsh-tokenlens-chart-body' },
3840
- 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,
3841
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(
3842
4069
  'div',
3843
4070
  {
3844
- key: 'plot',
3845
- className: 'dsh-tokenlens-chart-plot',
3846
- 'data-chart-plot': 'true',
3847
- onMouseMove: handleMove,
3848
- onMouseLeave: handleLeave,
3849
- onPointerMove: handleMove,
3850
- 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
+ }),
3851
4081
  },
3852
- h('div', { key: 'grid', className: 'dsh-tokenlens-chart-grid', 'aria-hidden': 'true' }, gridLines),
3853
- h('div', { key: 'bars', className: 'dsh-tokenlens-chart-bars' }, bars),
3854
- crosshair,
3855
- readout,
3856
- 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) }),
3857
4118
  ),
3858
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,
3859
4173
  h(
3860
4174
  'div',
3861
4175
  { key: 'summary', className: 'dsh-tokenlens-chart-summary' },
@@ -4054,6 +4368,83 @@ window.__ModuleLoader__.load({
4054
4368
  return t('statsLast30Days')
4055
4369
  }
4056
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
+
4057
4448
  /**
4058
4449
  * The compact 明细 table columns of one group (v0.30.2). Harness/provider
4059
4450
  * rows start at 今日; the all-harness top-model table starts at the
@@ -4365,16 +4756,17 @@ window.__ModuleLoader__.load({
4365
4756
  if (card !== null) section.push(card)
4366
4757
  else section.push(h('div', { key: 'no-balance', className: 'dsh-tokenlens-hint' }, t('chipNoUsage')))
4367
4758
  } else if (snapshot.provider.kind === 'windows') {
4759
+ // A windows provider may ALSO report an independent wallet balance
4760
+ // (OpenDesign): show it as one extra row of the same capacity card,
4761
+ // after the allowance windows (or after the "no usage" hint).
4762
+ const balanceRow = buildCapacityBalanceRow(snapshot.balance, t)
4368
4763
  if (snapshot.windows !== null) {
4369
- section.push(
4370
- h(
4371
- 'div',
4372
- { key: 'rows', className: 'dsh-tokenlens-rows' },
4373
- WINDOW_ORDER.map((id) => buildWindowRow(id, snapshot.windows[id], state, now, t, statsRow)),
4374
- ),
4375
- )
4764
+ const rows = WINDOW_ORDER.map((id) => buildWindowRow(id, snapshot.windows[id], state, now, t, statsRow))
4765
+ if (balanceRow !== null) rows.push(balanceRow)
4766
+ section.push(h('div', { key: 'rows', className: 'dsh-tokenlens-rows' }, rows))
4376
4767
  } else {
4377
4768
  section.push(h('div', { key: 'no-windows', className: 'dsh-tokenlens-hint' }, t('chipNoUsage')))
4769
+ if (balanceRow !== null) section.push(balanceRow)
4378
4770
  }
4379
4771
  } else {
4380
4772
  section.push(h('div', { key: 'no-kind', className: 'dsh-tokenlens-hint' }, t('chipNoUsage')))
@@ -4725,10 +5117,18 @@ window.__ModuleLoader__.load({
4725
5117
  )
4726
5118
  }
4727
5119
 
4728
- /** 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
+ */
4729
5128
  function buildStatsDetailsCharts(state, ctx, t) {
4730
- const { stats, hasMonth } = ctx
5129
+ const { stats } = ctx
4731
5130
  if (stats === null) return null
5131
+ const windowKey = detailsWindowOf(state, ctx)
4732
5132
  const children = [
4733
5133
  h(
4734
5134
  'div',
@@ -4740,17 +5140,18 @@ window.__ModuleLoader__.load({
4740
5140
  h('span', { key: 'label' }, t('statsDetailsTitle')),
4741
5141
  ),
4742
5142
  ),
5143
+ buildDetailsWindowSwitch(state, ctx, t),
4743
5144
  ]
5145
+ const groups = []
4744
5146
  const harnessRows = scopedHarnessRows(ctx).map((row) => ({
4745
5147
  label: row.label,
4746
- value: ctx.secondBucketOf(row.buckets)?.total ?? 0,
5148
+ value: detailsWindowTotalOf(windowKey, row.buckets, ctx.secondBucketOf),
4747
5149
  }))
4748
- const groups = []
4749
5150
  const harnessChart = buildBreakdownDonutGroup('harness-chart', t('statsByHarness'), 'list', harnessRows, t)
4750
5151
  if (harnessChart !== null) groups.push(harnessChart)
4751
5152
  const providerRows = stats.providers.map((provider) => ({
4752
5153
  label: provider.label,
4753
- value: ctx.secondBucketOf(provider)?.total ?? 0,
5154
+ value: detailsWindowTotalOf(windowKey, provider, ctx.secondBucketOf),
4754
5155
  }))
4755
5156
  const providerChart = buildBreakdownDonutGroup('provider-chart', t('statsByProvider'), 'server', providerRows, t)
4756
5157
  if (providerChart !== null) groups.push(providerChart)
@@ -4759,18 +5160,25 @@ window.__ModuleLoader__.load({
4759
5160
  // models from other providers silently disappear from the donut (v0.27.1).
4760
5161
  const modelRows = stats.models.map((model) => ({
4761
5162
  label: model.modelId,
4762
- value: ctx.secondModelOf(model)?.total ?? 0,
5163
+ value: detailsWindowTotalOf(windowKey, model, ctx.secondModelOf),
4763
5164
  }))
4764
5165
  const modelChart = buildBreakdownDonutGroup('model-chart', t('statsTopModels'), 'cube', modelRows, t)
4765
5166
  if (modelChart !== null) groups.push(modelChart)
4766
5167
  if (groups.length > 0) {
4767
5168
  children.push(h('div', { key: 'charts', className: 'dsh-tokenlens-details-charts-grid' }, groups))
4768
- }
4769
- if (children.length === 1) {
5169
+ } else {
5170
+ // An all-zero window renders no donut at all: keep the readable hint.
4770
5171
  children.push(h('div', { key: 'empty', className: 'dsh-tokenlens-hint' }, t('statsEmpty')))
4771
5172
  }
4772
- void hasMonth
4773
- 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
+ )
4774
5182
  }
4775
5183
 
4776
5184
  function buildStatsDetails(state, ctx, t) {