dsh-tokenlens 1.1.0 → 1.2.1

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
@@ -66,6 +66,8 @@ window.__ModuleLoader__.load({
66
66
  const PROVIDERS_TTL_MS = 30000
67
67
  /** Auto-sync cadence of the provider list while the management page is open (v0.17.0). */
68
68
  const MANAGEMENT_SYNC_MS = 15000
69
+ /** Retry delay of a 明细-window save blocked by an in-flight settings request (v1.2.1). */
70
+ const DETAILS_WINDOW_SAVE_RETRY_MS = 200
69
71
  /** How many per-provider / per-model stats rows the dialog lists. */
70
72
  const MAX_PROVIDER_ROWS = 5
71
73
  const MAX_MODEL_ROWS = 3
@@ -125,14 +127,16 @@ window.__ModuleLoader__.load({
125
127
  /** Fixed segment palette of the 明细 donut charts (v0.27.0, color-blind friendly order). */
126
128
  const DONUT_PALETTE = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#06b6d4', '#f97316']
127
129
  /**
128
- * Host-side display defaults (v0.29.0). 「恢复默认设置」 sends exactly these;
129
- * must stay in sync with DEFAULT_SETTINGS in lib/provider-store.mjs.
130
+ * Host-side display defaults (v0.29.0; the five fields include the v1.2.1
131
+ * 明细 donut window). 「恢复默认设置」 sends exactly these; must stay in sync
132
+ * with DEFAULT_SETTINGS in lib/provider-store.mjs.
130
133
  */
131
134
  const DEFAULT_PLUGIN_SETTINGS = {
132
135
  labelStyle: 'full',
133
136
  defaultView: 'chart',
134
137
  defaultScope: 'provider',
135
138
  detailsExpanded: 'auto',
139
+ detailsWindow: 'total',
136
140
  }
137
141
 
138
142
  const zh = {
@@ -208,6 +212,12 @@ window.__ModuleLoader__.load({
208
212
  statsChartPeak: '峰值 {value}({day})',
209
213
  statsChartBarTitle: '{date}: {tokens}',
210
214
  statsChartReadout: '{date} · {value}({share}%)',
215
+ statsChartBack: '← 返回日趋势',
216
+ statsChartBackHint: '返回每日趋势图',
217
+ statsChartHourTitle: '按小时 · {date}',
218
+ statsChartHourBarTitle: '{date} {hour} · {tokens}',
219
+ statsChartHourEmpty: '该日没有小时级数据',
220
+ statsWindowLabel: '明细统计窗口',
211
221
  statsByHarness: '按 Harness',
212
222
  statsHarnessUnavailable: '不可用',
213
223
  statsHarnessRetryHint: '检查本机 Harness 数据源后点刷新重试',
@@ -429,6 +439,12 @@ window.__ModuleLoader__.load({
429
439
  statsChartPeak: 'Peak {value} ({day})',
430
440
  statsChartBarTitle: '{date}: {tokens}',
431
441
  statsChartReadout: '{date} · {value} ({share}%)',
442
+ statsChartBack: '← Back to daily',
443
+ statsChartBackHint: 'Back to the daily trend chart',
444
+ statsChartHourTitle: 'By hour · {date}',
445
+ statsChartHourBarTitle: '{date} {hour} · {tokens}',
446
+ statsChartHourEmpty: 'No hourly data for this day',
447
+ statsWindowLabel: 'Details window',
432
448
  statsByHarness: 'By harness',
433
449
  statsHarnessUnavailable: 'Unavailable',
434
450
  statsHarnessRetryHint: 'Check the harness data source, then click Refresh to retry',
@@ -1089,9 +1105,15 @@ window.__ModuleLoader__.load({
1089
1105
  return raw === 'expanded' || raw === 'collapsed' ? raw : 'auto'
1090
1106
  }
1091
1107
 
1108
+ /** One `detailsWindow` setting: 'today' | 'last7Days' | 'second' | 'total' (junk means 总计). */
1109
+ function normalizeDetailsWindow(raw) {
1110
+ return raw === 'today' || raw === 'last7Days' || raw === 'second' ? raw : 'total'
1111
+ }
1112
+
1092
1113
  /**
1093
- * The four display settings of the `providers` payload (v0.29.0), every field
1094
- * normalized to a usable value so old Hosts keep the previous defaults.
1114
+ * The five display settings of the `providers` payload (v0.29.0; the 明细
1115
+ * donut window joined them in v1.2.1), every field normalized to a usable
1116
+ * value so old Hosts keep the previous defaults.
1095
1117
  */
1096
1118
  function normalizePluginSettings(raw) {
1097
1119
  const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
@@ -1100,6 +1122,7 @@ window.__ModuleLoader__.load({
1100
1122
  defaultView: normalizeDisplayView(source.defaultView),
1101
1123
  defaultScope: normalizeDisplayScope(source.defaultScope),
1102
1124
  detailsExpanded: normalizeDetailsExpanded(source.detailsExpanded),
1125
+ detailsWindow: normalizeDetailsWindow(source.detailsWindow),
1103
1126
  }
1104
1127
  }
1105
1128
 
@@ -1238,6 +1261,17 @@ window.__ModuleLoader__.load({
1238
1261
  }
1239
1262
  }
1240
1263
 
1264
+ /**
1265
+ * The local 0..23 hour histogram of one daily entry (v1.2.0), or null when
1266
+ * the wire omits it (old Hosts). Anything but a length-24 array is treated
1267
+ * as unsupported; a non-finite / negative slot degrades to 0 so hostile data
1268
+ * can never poison the chart.
1269
+ */
1270
+ function normalizeHourHistogram(raw) {
1271
+ if (!Array.isArray(raw) || raw.length !== 24) return null
1272
+ return raw.map((value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0))
1273
+ }
1274
+
1241
1275
  /** One provider attribution row (stats v3 aggregates DSH + ZCode per provider). */
1242
1276
  function normalizeProviderRow(entry) {
1243
1277
  const providerId = stringOrEmpty(entry.providerId)
@@ -1358,6 +1392,8 @@ window.__ModuleLoader__.load({
1358
1392
  : []
1359
1393
  // v0.24.0: the Host reports a per-day token series (ascending, <=120 days)
1360
1394
  // for the chart mode. Every field is normalized defensively.
1395
+ // v1.2.0: each day (and each provider split of that day) may also carry
1396
+ // the local 0..23 hour histogram used by the chart drill-down.
1361
1397
  const daily = Array.isArray(raw.daily)
1362
1398
  ? raw.daily
1363
1399
  .filter((entry) => entry !== null && typeof entry === 'object')
@@ -1369,10 +1405,18 @@ window.__ModuleLoader__.load({
1369
1405
  const dailyProviders = {}
1370
1406
  for (const [providerId, bucketRaw] of Object.entries(providerRaw)) {
1371
1407
  if (providerId === '' || bucketRaw === null || typeof bucketRaw !== 'object') continue
1372
- dailyProviders[providerId] = normalizeBucket(bucketRaw)
1408
+ dailyProviders[providerId] = {
1409
+ ...normalizeBucket(bucketRaw),
1410
+ hours: normalizeHourHistogram(bucketRaw.hours),
1411
+ }
1373
1412
  }
1374
1413
  const day = stringOrEmpty(entry.day).trim()
1375
- return { day, total: normalizeOptionalBucket(entry.total), providers: dailyProviders }
1414
+ return {
1415
+ day,
1416
+ total: normalizeOptionalBucket(entry.total),
1417
+ hours: normalizeHourHistogram(entry.hours),
1418
+ providers: dailyProviders,
1419
+ }
1376
1420
  })
1377
1421
  .filter((entry) => entry.day !== '')
1378
1422
  .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0))
@@ -1706,6 +1750,13 @@ window.__ModuleLoader__.load({
1706
1750
  // v0.25.0 chart hover: the nearest bar index under the pointer, or null.
1707
1751
  // Reset on mouse leave and whenever the scope/view/provider changes.
1708
1752
  chartHover: null,
1753
+ // v1.2.0 chart drill-down: the clicked day whose 0..23 hour histogram
1754
+ // is charted instead of the daily trend, or null (daily trend).
1755
+ chartDay: null,
1756
+ // v1.2.0 明细 donut window: 'today' | 'last7Days' | 'second' | 'total'.
1757
+ // v1.2.1: persisted setting (the last choice survives reopening the dialog
1758
+ // and restarting DSH; the startup bootstrap loads it back).
1759
+ detailsWindow: DEFAULT_PLUGIN_SETTINGS.detailsWindow,
1709
1760
  // Provider management page (v0.9.0).
1710
1761
  view: 'usage', // 'usage' | 'management'
1711
1762
  management: defaultManagementState(),
@@ -1756,7 +1807,10 @@ window.__ModuleLoader__.load({
1756
1807
  /** Bumped whenever the selected provider changes; stale responses are dropped. */
1757
1808
  let usageEpoch = 0
1758
1809
 
1759
- /** The four display settings currently in effect (v0.29.0). */
1810
+ /**
1811
+ * The five display settings currently in effect (v0.29.0; the 明细 donut
1812
+ * window joined them in v1.2.1).
1813
+ */
1760
1814
  function currentPluginSettings() {
1761
1815
  const state = store.get()
1762
1816
  return {
@@ -1764,6 +1818,7 @@ window.__ModuleLoader__.load({
1764
1818
  defaultView: normalizeDisplayView(state.defaultView),
1765
1819
  defaultScope: normalizeDisplayScope(state.defaultScope),
1766
1820
  detailsExpanded: normalizeDetailsExpanded(state.detailsExpanded),
1821
+ detailsWindow: normalizeDetailsWindow(state.detailsWindow),
1767
1822
  }
1768
1823
  }
1769
1824
 
@@ -1786,7 +1841,13 @@ window.__ModuleLoader__.load({
1786
1841
  statsView: settings.defaultView,
1787
1842
  statsScope: settings.defaultScope,
1788
1843
  showDetails: showDetailsFor(settings.detailsExpanded),
1844
+ // v1.2.1: the 明细 donut window is a persisted setting, so reopening the
1845
+ // dialog or switching provider keeps the last saved choice instead of
1846
+ // always snapping back to 总计 (detailsWindowOf still falls back when the
1847
+ // chosen window is absent from the stats payload).
1848
+ detailsWindow: settings.detailsWindow,
1789
1849
  chartHover: null,
1850
+ chartDay: null,
1790
1851
  }
1791
1852
  }
1792
1853
 
@@ -1983,11 +2044,16 @@ window.__ModuleLoader__.load({
1983
2044
 
1984
2045
  /**
1985
2046
  * Adopt the display settings and wire label cache of a providers payload
1986
- * (v0.10.0, extended v0.29.0 with the plugin display defaults). The
1987
- * management list itself is applied by the caller, so a read-only startup
1988
- * load can reuse this without touching the page state.
2047
+ * (v0.10.0, extended v0.29.0 with the plugin display defaults).
2048
+ *
2049
+ * `options.adoptDetailsWindow` is only set by the startup bootstrap: the
2050
+ * persisted 明细 window must be loaded once, but a plain settingsUpdate
2051
+ * answer must never repaint a just-clicked pill (the answer of an earlier
2052
+ * save would otherwise overwrite the last of a fast click series, v1.2.1).
2053
+ * The management list itself is applied by the caller, so a read-only
2054
+ * startup load can reuse this without touching the page state.
1989
2055
  */
1990
- function adoptProvidersMeta(parsed) {
2056
+ function adoptProvidersMeta(parsed, options = {}) {
1991
2057
  if (parsed === null || typeof parsed !== 'object') return
1992
2058
  const current = store.get()
1993
2059
  const patch = {}
@@ -1996,6 +2062,9 @@ window.__ModuleLoader__.load({
1996
2062
  if (current.defaultView !== settings.defaultView) patch.defaultView = settings.defaultView
1997
2063
  if (current.defaultScope !== settings.defaultScope) patch.defaultScope = settings.defaultScope
1998
2064
  if (current.detailsExpanded !== settings.detailsExpanded) patch.detailsExpanded = settings.detailsExpanded
2065
+ if (options.adoptDetailsWindow === true && current.detailsWindow !== settings.detailsWindow) {
2066
+ patch.detailsWindow = settings.detailsWindow
2067
+ }
1999
2068
  if (parsed.labelCatalog !== null && typeof parsed.labelCatalog === 'object') {
2000
2069
  const catalog = { ...current.labelCatalog, ...parsed.labelCatalog }
2001
2070
  if (Object.keys(catalog).length > 0) patch.labelCatalog = catalog
@@ -2006,10 +2075,11 @@ window.__ModuleLoader__.load({
2006
2075
  /**
2007
2076
  * Read the providers payload once at controller startup (v0.10.0).
2008
2077
  *
2009
- * Only the display style and the wire label cache are adopted: the chip must
2010
- * honor a persisted 'full' style before the 管理 page is ever opened, while
2011
- * the page keeps its own lazy load. Silent on failure — an old Host without
2012
- * the action (or an unreachable bridge) keeps the short-label defaults.
2078
+ * Only the display style, the persisted 明细 window and the wire label
2079
+ * cache are adopted: the chip must honor a persisted 'full' style and the
2080
+ * dialog must restore the saved donut window before the 管理 page is ever
2081
+ * opened, while the page keeps its own lazy load. Silent on failure — an
2082
+ * old Host without the action (or an unreachable bridge) keeps the defaults.
2013
2083
  */
2014
2084
  async function bootstrapProviders() {
2015
2085
  let data
@@ -2021,7 +2091,7 @@ window.__ModuleLoader__.load({
2021
2091
  if (data === null || typeof data !== 'object' || data.ok !== true) return { kind: 'envelope' }
2022
2092
  const parsed = normalizeProvidersPayload(data)
2023
2093
  if (parsed === null) return { kind: 'transport' }
2024
- adoptProvidersMeta(parsed)
2094
+ adoptProvidersMeta(parsed, { adoptDetailsWindow: true })
2025
2095
  return { kind: 'ok' }
2026
2096
  }
2027
2097
 
@@ -2162,10 +2232,11 @@ window.__ModuleLoader__.load({
2162
2232
  }
2163
2233
 
2164
2234
  /**
2165
- * Persist one display-settings change (v0.29.0): the FULL four-field settings
2166
- * object is always sent, because the Host accepts any subset but the page
2167
- * keeps a single source of truth. The answered payload's settings, wire
2168
- * labels and provider list are adopted. Never throws.
2235
+ * Persist one display-settings change (v0.29.0): the FULL five-field settings
2236
+ * object is always sent (the v1.2.1 明细 window included), because the Host
2237
+ * accepts any subset but the page keeps a single source of truth. The
2238
+ * answered payload's settings, wire labels and provider list are adopted.
2239
+ * Never throws.
2169
2240
  */
2170
2241
  async function requestSettingsUpdate(settings) {
2171
2242
  const next = normalizePluginSettings(settings)
@@ -2214,7 +2285,37 @@ window.__ModuleLoader__.load({
2214
2285
  return { kind: 'transport' }
2215
2286
  }
2216
2287
 
2217
- /** Change one display setting; the other three keep their current values. */
2288
+ /** Pending retry timer of the 明细-window save (a single timer, v1.2.1). */
2289
+ let detailsWindowSaveTimer = null
2290
+
2291
+ /**
2292
+ * Persist the 明细 donut window choice (v1.2.1).
2293
+ *
2294
+ * The store field is also the persisted setting value, so a pill click saves
2295
+ * the FULL current settings through `settingsUpdate`. When another settings
2296
+ * request is still in flight, one 200ms retry timer keeps the LAST click of a
2297
+ * fast series (the earlier answer must not overwrite it: adoptProvidersMeta
2298
+ * ignores detailsWindow for non-bootstrap answers). The bridge answers
2299
+ * locally within a few milliseconds, so a single retry is enough. The Host's
2300
+ * answer then refreshes the provider list as usual.
2301
+ */
2302
+ function persistDetailsWindow() {
2303
+ if (detailsWindowSaveTimer !== null) {
2304
+ window.clearTimeout(detailsWindowSaveTimer)
2305
+ detailsWindowSaveTimer = null
2306
+ }
2307
+ if (store.get().management.settingsBusy === true) {
2308
+ detailsWindowSaveTimer = window.setTimeout(() => {
2309
+ detailsWindowSaveTimer = null
2310
+ if (store.get().management.settingsBusy === true) return
2311
+ void requestSettingsUpdate(currentPluginSettings())
2312
+ }, DETAILS_WINDOW_SAVE_RETRY_MS)
2313
+ return
2314
+ }
2315
+ void requestSettingsUpdate(currentPluginSettings())
2316
+ }
2317
+
2318
+ /** Change one display setting; the other four keep their current values. */
2218
2319
  function savePluginSetting(field, value) {
2219
2320
  if (store.get().management.settingsBusy === true) return
2220
2321
  const next = { ...currentPluginSettings(), [field]: value }
@@ -2224,6 +2325,9 @@ window.__ModuleLoader__.load({
2224
2325
  /** 「恢复默认设置」: send the Host-side display defaults as one full payload. */
2225
2326
  function resetPluginSettings() {
2226
2327
  if (store.get().management.settingsBusy === true) return
2328
+ // v1.2.1: reset the persisted 明细 window locally too — a plain
2329
+ // settingsUpdate answer intentionally does not adopt that field.
2330
+ store.set({ detailsWindow: DEFAULT_PLUGIN_SETTINGS.detailsWindow })
2227
2331
  void requestSettingsUpdate({ ...DEFAULT_PLUGIN_SETTINGS })
2228
2332
  }
2229
2333
 
@@ -2857,6 +2961,19 @@ window.__ModuleLoader__.load({
2857
2961
  .dsh-tokenlens-chart-summary>span{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
2858
2962
  .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
2963
  .dsh-tokenlens-chart-empty-hint{opacity:.8;font-size:11px}
2964
+ /* Hourly drill-down of the daily chart (v1.2.0): clickable/focusable day bars,
2965
+ a selected-day ring, and the back + title row above the hour bars. */
2966
+ .dsh-tokenlens-chart-bar-wrap-clickable{cursor:pointer}
2967
+ .dsh-tokenlens-chart-bar-wrap-clickable:focus-visible{outline:2px solid #2e9e5b;outline-offset:1px;border-radius:3px}
2968
+ .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)}
2969
+ .dsh-tokenlens-chart-drilldown{gap:4px}
2970
+ .dsh-tokenlens-chart-drill-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
2971
+ .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}
2972
+ .dsh-tokenlens-chart-back:hover{color:var(--dsw-alias-label-secondary)}
2973
+ .dsh-tokenlens-chart-back:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5))}
2974
+ .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}
2975
+ /* 明细 donut window switch (v1.2.0): one pill row under the details title. */
2976
+ .dsh-tokenlens-details-window{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
2860
2977
  .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75}
2861
2978
  .dsh-tokenlens-cards{gap:6px;flex-wrap:wrap;display:flex}
2862
2979
  .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 +3672,7 @@ window.__ModuleLoader__.load({
3555
3672
  'data-scope': scope,
3556
3673
  'data-active': effective === scope ? 'true' : 'false',
3557
3674
  disabled: stats === null,
3558
- onClick: () => store.set({ statsScope: scope, chartHover: null }),
3675
+ onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null }),
3559
3676
  },
3560
3677
  label,
3561
3678
  )
@@ -3581,7 +3698,7 @@ window.__ModuleLoader__.load({
3581
3698
  'data-active': active === view ? 'true' : 'false',
3582
3699
  'aria-pressed': active === view ? 'true' : 'false',
3583
3700
  onClick: () => {
3584
- if (active !== view) store.set({ statsView: view, chartHover: null })
3701
+ if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null })
3585
3702
  },
3586
3703
  },
3587
3704
  Icon(iconName, 12),
@@ -3601,37 +3718,88 @@ window.__ModuleLoader__.load({
3601
3718
  }
3602
3719
 
3603
3720
  /**
3604
- * The daily series of the chart (v0.24.0).
3721
+ * The daily provider ids the chart's provider scope matches (v0.24.0).
3722
+ *
3723
+ * Both the stats row id and the session-selected id are matched, in exact and
3724
+ * canonical form, so `deepseek-official` and `deepseek` find the same bucket.
3725
+ */
3726
+ function chartDailyProviderIds(ctx) {
3727
+ const wanted = []
3728
+ if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
3729
+ wanted.push(ctx.currentRow.providerId)
3730
+ }
3731
+ if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
3732
+ wanted.push(ctx.selectedProviderId)
3733
+ }
3734
+ return { wanted, canonicalWanted: new Set(wanted.map((id) => canonicalProviderId(id))) }
3735
+ }
3736
+
3737
+ /** The daily provider bucket of one day in provider scope, or null. */
3738
+ function chartDailyProviderBucket(ctx, entry) {
3739
+ const { wanted, canonicalWanted } = chartDailyProviderIds(ctx)
3740
+ for (const [key, bucket] of Object.entries(entry.providers)) {
3741
+ if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket ?? null
3742
+ }
3743
+ return null
3744
+ }
3745
+
3746
+ /** The 24-hour histogram of one chart point, or null when unsupported. */
3747
+ function chartHoursOfPoint(point) {
3748
+ return point !== null && point !== undefined && Array.isArray(point.hours) && point.hours.length === 24
3749
+ ? point.hours
3750
+ : null
3751
+ }
3752
+
3753
+ /** The `HH:00` label of one local hour of the drill-down chart (v1.2.0). */
3754
+ function chartHourLabel(hour) {
3755
+ return `${String(hour).padStart(2, '0')}:00`
3756
+ }
3757
+
3758
+ /** Enter / Space activate a focusable chart bar (v1.2.0). */
3759
+ function isChartActivationKey(event) {
3760
+ const key = typeof event?.key === 'string' ? event.key : ''
3761
+ return key === 'Enter' || key === ' ' || key === 'Spacebar'
3762
+ }
3763
+
3764
+ /**
3765
+ * Toggle the chart drill-down (v1.2.0): clicking a day charts its 0..23 hour
3766
+ * histogram; clicking the SAME day again returns to the daily trend.
3767
+ */
3768
+ function activateChartDay(day) {
3769
+ if (typeof day !== 'string' || day === '') return
3770
+ const next = store.get().chartDay === day ? null : day
3771
+ store.set({ chartDay: next, chartHover: null })
3772
+ }
3773
+
3774
+ /** Leave the hourly drill-down and chart the daily trend again (v1.2.0). */
3775
+ function clearChartDay() {
3776
+ if (store.get().chartDay !== null) store.set({ chartDay: null, chartHover: null })
3777
+ }
3778
+
3779
+ /**
3780
+ * The daily series of the chart (v0.24.0; v1.2.0 adds the day histograms).
3605
3781
  *
3606
3782
  * Provider scope reads the selected provider's daily bucket and limits the
3607
3783
  * window to the provider's effective day when it is subscription-backed
3608
3784
  * (fallback: the last 30 days). The all-harness scope uses the day totals of
3609
- * the last 30 days.
3785
+ * the last 30 days. Every point also carries its 0..23 hour histogram
3786
+ * (`hours`, or null on old Hosts) so the chart can drill into one day.
3610
3787
  */
3611
3788
  function chartSeriesOf(ctx) {
3612
3789
  const stats = ctx.stats
3613
3790
  if (stats === null) return []
3614
3791
  const daily = stats.daily
3615
3792
  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
3793
+ return daily.slice(-30).map((entry) => ({
3794
+ day: entry.day,
3795
+ total: entry.total?.total ?? 0,
3796
+ hours: entry.hours ?? null,
3797
+ }))
3633
3798
  }
3634
- const series = daily.map((entry) => ({ day: entry.day, total: valueOf(entry) }))
3799
+ const series = daily.map((entry) => {
3800
+ const bucket = chartDailyProviderBucket(ctx, entry)
3801
+ return { day: entry.day, total: bucket?.total ?? 0, hours: bucket?.hours ?? null }
3802
+ })
3635
3803
  const effectiveDay = ctx.currentRow !== null ? ctx.currentRow.effectiveDay : ''
3636
3804
  if (ctx.subscribed && typeof effectiveDay === 'string' && effectiveDay !== '') {
3637
3805
  return series.filter((point) => point.day >= effectiveDay)
@@ -3710,7 +3878,6 @@ window.__ModuleLoader__.load({
3710
3878
  if (store.get().chartHover !== next) store.set({ chartHover: next })
3711
3879
  }
3712
3880
 
3713
- /** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
3714
3881
  /** The active today bucket of the current stats scope (v0.27.2). */
3715
3882
  function todayBucketOf(ctx) {
3716
3883
  return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
@@ -3727,37 +3894,25 @@ window.__ModuleLoader__.load({
3727
3894
  return ctx.providerScoped ? ctx.currentRow?.last7Days ?? null : ctx.stats?.last7Days ?? null
3728
3895
  }
3729
3896
 
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
- }
3897
+ /**
3898
+ * The shared pure-CSS bar plot of the chart modes (v0.25.0; v1.2.0 reuses
3899
+ * it for the hourly drill-down): Y-axis ticks, gridlines, bars, the X-axis
3900
+ * labels, the crosshair and the live pointer readout. Pure DOM, no deps.
3901
+ *
3902
+ * `spec.bar(point, index, meta)` returns the wrapper element of one bar
3903
+ * (`meta.max` / `meta.hovered` drive its height and highlight),
3904
+ * `spec.xLabel(point, index)` returns `{text, attrs}` for the X axis, and
3905
+ * `spec.readout(hovered, share)` returns the live readout text.
3906
+ */
3907
+ function buildChartPlot(state, points, spec) {
3749
3908
  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
- }
3909
+ const total = points.reduce((sum, point) => sum + point.total, 0)
3754
3910
  const hoverIndex =
3755
3911
  Number.isInteger(state.chartHover) && state.chartHover >= 0 && state.chartHover < points.length
3756
3912
  ? state.chartHover
3757
3913
  : null
3758
3914
  const hovered = hoverIndex === null ? null : points[hoverIndex]
3759
- const hoverShare =
3760
- hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
3915
+ const hoverShare = hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
3761
3916
  const handleMove = (event) => {
3762
3917
  const index = chartIndexFromEvent(event, points.length)
3763
3918
  if (index !== null) setChartHover(index)
@@ -3783,32 +3938,10 @@ window.__ModuleLoader__.load({
3783
3938
  tick.text,
3784
3939
  ),
3785
3940
  )
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
- })
3941
+ const bars = points.map((point, index) => spec.bar(point, index, { max, hovered: index === hoverIndex }))
3810
3942
  const xLabels = chartXLabelIndices(points.length).map((index) => {
3811
3943
  const point = points[index]
3944
+ const label = spec.xLabel(point, index)
3812
3945
  const first = index === 0
3813
3946
  const last = index === points.length - 1
3814
3947
  const style = first
@@ -3819,12 +3952,12 @@ window.__ModuleLoader__.load({
3819
3952
  return h(
3820
3953
  'span',
3821
3954
  {
3822
- key: `x-${point.day}`,
3955
+ key: `x-${index}`,
3823
3956
  className: 'dsh-tokenlens-chart-x-label',
3824
- 'data-chart-axis-day': point.day,
3957
+ ...(label.attrs === null || label.attrs === undefined ? {} : label.attrs),
3825
3958
  style,
3826
3959
  },
3827
- chartDayLabel(point.day),
3960
+ label.text,
3828
3961
  )
3829
3962
  })
3830
3963
  const crosshair =
@@ -3857,37 +3990,249 @@ window.__ModuleLoader__.load({
3857
3990
  role: 'status',
3858
3991
  style: readoutStyle,
3859
3992
  },
3860
- t('statsChartReadout', {
3861
- date: hovered.day,
3862
- value: formatTokensPrecise(hovered.total),
3863
- share: String(hoverShare),
3864
- }),
3993
+ spec.readout(hovered, hoverShare),
3865
3994
  )
3866
3995
  return h(
3867
3996
  'div',
3868
- { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true' },
3997
+ { key: 'body', className: 'dsh-tokenlens-chart-body' },
3998
+ h('div', { key: 'y-axis', className: 'dsh-tokenlens-chart-yaxis', 'aria-hidden': 'true' }, yTicks),
3999
+ h(
4000
+ 'div',
4001
+ {
4002
+ key: 'plot',
4003
+ className: 'dsh-tokenlens-chart-plot',
4004
+ 'data-chart-plot': 'true',
4005
+ onMouseMove: handleMove,
4006
+ onMouseLeave: handleLeave,
4007
+ onPointerMove: handleMove,
4008
+ onPointerLeave: handleLeave,
4009
+ },
4010
+ h('div', { key: 'grid', className: 'dsh-tokenlens-chart-grid', 'aria-hidden': 'true' }, gridLines),
4011
+ h('div', { key: 'bars', className: 'dsh-tokenlens-chart-bars' }, bars),
4012
+ crosshair,
4013
+ readout,
4014
+ h('div', { key: 'xaxis', className: 'dsh-tokenlens-chart-xaxis', 'aria-hidden': 'true' }, xLabels),
4015
+ ),
4016
+ )
4017
+ }
4018
+
4019
+ /**
4020
+ * One bar of the daily trend chart (v0.25.0). A day whose entry carries the
4021
+ * 0..23 hour histogram is ALSO a real button (v1.2.0): focusable, Enter /
4022
+ * Space activate it, `aria-pressed` marks the drilled day. A day without
4023
+ * hours is not drillable and keeps the old non-interactive bar.
4024
+ */
4025
+ function buildDailyChartBar(state, t, point, meta) {
4026
+ const height = point.total > 0 ? Math.max(2, Math.round((point.total / meta.max) * 1000) / 10) : 0
4027
+ const drillable = chartHoursOfPoint(point) !== null
4028
+ const selected = drillable && state.chartDay === point.day
4029
+ const attrs = {
4030
+ key: point.day,
4031
+ className: drillable
4032
+ ? 'dsh-tokenlens-chart-bar-wrap dsh-tokenlens-chart-bar-wrap-clickable'
4033
+ : 'dsh-tokenlens-chart-bar-wrap',
4034
+ 'data-chart-day': point.day,
4035
+ 'data-chart-total': String(point.total),
4036
+ 'data-chart-hover': meta.hovered ? 'true' : 'false',
4037
+ 'data-chart-drillable': drillable ? 'true' : 'false',
4038
+ 'data-chart-selected': selected ? 'true' : 'false',
4039
+ title: t('statsChartBarTitle', {
4040
+ date: point.day,
4041
+ tokens: formatTokensPrecise(point.total),
4042
+ }),
4043
+ }
4044
+ if (drillable) {
4045
+ attrs.role = 'button'
4046
+ attrs.tabIndex = 0
4047
+ attrs['aria-pressed'] = selected ? 'true' : 'false'
4048
+ attrs['aria-label'] = t('statsChartBarTitle', {
4049
+ date: point.day,
4050
+ tokens: formatTokensPrecise(point.total),
4051
+ })
4052
+ attrs.onClick = () => activateChartDay(point.day)
4053
+ attrs.onKeyDown = (event) => {
4054
+ if (!isChartActivationKey(event)) return
4055
+ if (typeof event?.preventDefault === 'function') event.preventDefault()
4056
+ activateChartDay(point.day)
4057
+ }
4058
+ }
4059
+ return h(
4060
+ 'div',
4061
+ attrs,
4062
+ h('div', {
4063
+ className: 'dsh-tokenlens-chart-bar',
4064
+ 'data-empty': point.total > 0 ? 'false' : 'true',
4065
+ 'data-height': String(height),
4066
+ style: { height: `${height}%` },
4067
+ }),
4068
+ )
4069
+ }
4070
+
4071
+ /**
4072
+ * The drilled day view (v1.2.0): one day's 00:00–23:00 token bars, with a
4073
+ * back button to the daily trend. It reuses the shared plot (axes, pointer
4074
+ * readout, tooltips) and the existing summary formatters.
4075
+ */
4076
+ function buildHourChart(state, point, hours, t) {
4077
+ const dayLabel = chartDayLabel(point.day)
4078
+ const hourPoints = hours.map((value, hour) => ({ hour, total: value }))
4079
+ const total = hourPoints.reduce((sum, hourPoint) => sum + hourPoint.total, 0)
4080
+ const head = h(
4081
+ 'div',
4082
+ { key: 'drill-head', className: 'dsh-tokenlens-chart-drill-head', 'data-chart-drill-head': 'true' },
4083
+ h(
4084
+ 'button',
4085
+ {
4086
+ key: 'back',
4087
+ type: 'button',
4088
+ className: 'dsh-tokenlens-chart-back',
4089
+ 'data-chart-back': point.day,
4090
+ title: t('statsChartBackHint'),
4091
+ 'aria-label': t('statsChartBack'),
4092
+ onClick: () => clearChartDay(),
4093
+ },
4094
+ t('statsChartBack'),
4095
+ ),
3869
4096
  h(
4097
+ 'span',
4098
+ { key: 'title', className: 'dsh-tokenlens-chart-drill-title', 'data-chart-hour-title': point.day },
4099
+ t('statsChartHourTitle', { date: point.day }),
4100
+ ),
4101
+ )
4102
+ const containerAttrs = {
4103
+ key: 'chart',
4104
+ className: 'dsh-tokenlens-chart dsh-tokenlens-chart-drilldown',
4105
+ 'data-chart': 'true',
4106
+ 'data-chart-drilldown': 'true',
4107
+ 'data-chart-drill-day': point.day,
4108
+ }
4109
+ if (total <= 0) {
4110
+ // A hosted histogram that is all zeros carries no data: say so instead of
4111
+ // inventing an empty 24-bar chart (old Hosts never get this far: their
4112
+ // bars are not drillable at all).
4113
+ return h(
3870
4114
  'div',
3871
- { key: 'body', className: 'dsh-tokenlens-chart-body' },
3872
- h('div', { key: 'y-axis', className: 'dsh-tokenlens-chart-yaxis', 'aria-hidden': 'true' }, yTicks),
4115
+ containerAttrs,
4116
+ head,
3873
4117
  h(
4118
+ 'div',
4119
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-hour-empty': 'true' },
4120
+ h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartHourEmpty')),
4121
+ ),
4122
+ )
4123
+ }
4124
+ let peak = hourPoints[0]
4125
+ for (const hourPoint of hourPoints) {
4126
+ if (hourPoint.total > peak.total) peak = hourPoint
4127
+ }
4128
+ const body = buildChartPlot(state, hourPoints, {
4129
+ bar: (hourPoint, index, meta) => {
4130
+ const height = hourPoint.total > 0 ? Math.max(2, Math.round((hourPoint.total / meta.max) * 1000) / 10) : 0
4131
+ return h(
3874
4132
  'div',
3875
4133
  {
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,
4134
+ key: `hour-${hourPoint.hour}`,
4135
+ className: 'dsh-tokenlens-chart-bar-wrap',
4136
+ 'data-chart-hour': String(hourPoint.hour),
4137
+ 'data-chart-total': String(hourPoint.total),
4138
+ 'data-chart-hover': meta.hovered ? 'true' : 'false',
4139
+ title: t('statsChartHourBarTitle', {
4140
+ date: dayLabel,
4141
+ hour: chartHourLabel(hourPoint.hour),
4142
+ tokens: formatTokensPrecise(hourPoint.total),
4143
+ }),
3883
4144
  },
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),
4145
+ h('div', {
4146
+ className: 'dsh-tokenlens-chart-bar',
4147
+ 'data-empty': hourPoint.total > 0 ? 'false' : 'true',
4148
+ 'data-height': String(height),
4149
+ style: { height: `${height}%` },
4150
+ }),
4151
+ )
4152
+ },
4153
+ xLabel: (hourPoint) => ({
4154
+ text: chartHourLabel(hourPoint.hour),
4155
+ attrs: { 'data-chart-axis-hour': String(hourPoint.hour) },
4156
+ }),
4157
+ readout: (hovered, share) =>
4158
+ t('statsChartReadout', {
4159
+ date: `${dayLabel} ${chartHourLabel(hovered.hour)}`,
4160
+ value: formatTokensPrecise(hovered.total),
4161
+ share: String(share),
4162
+ }),
4163
+ })
4164
+ return h(
4165
+ 'div',
4166
+ containerAttrs,
4167
+ head,
4168
+ body,
4169
+ h(
4170
+ 'div',
4171
+ { key: 'summary', className: 'dsh-tokenlens-chart-summary' },
4172
+ h(
4173
+ 'span',
4174
+ { key: 'total', 'data-chart-summary': 'hour-total' },
4175
+ t('statsChartTotal', { value: formatTokensPrecise(total) }),
4176
+ ),
4177
+ h(
4178
+ 'span',
4179
+ { key: 'peak', 'data-chart-summary': 'hour-peak' },
4180
+ t('statsChartPeak', { value: formatTokensPrecise(peak.total), day: chartHourLabel(peak.hour) }),
3889
4181
  ),
3890
4182
  ),
4183
+ )
4184
+ }
4185
+
4186
+ /** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
4187
+ function buildDailyChart(state, ctx, t) {
4188
+ const points = chartSeriesOf(ctx)
4189
+ const total = points.reduce((sum, point) => sum + point.total, 0)
4190
+ if (points.length === 0 || total === 0) {
4191
+ return h(
4192
+ 'div',
4193
+ { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true', 'data-chart-empty-series': 'true' },
4194
+ h(
4195
+ 'div',
4196
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-empty': 'true' },
4197
+ h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartEmpty')),
4198
+ h(
4199
+ 'span',
4200
+ { key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
4201
+ t('statsChartEmptyHint'),
4202
+ ),
4203
+ ),
4204
+ )
4205
+ }
4206
+ // v1.2.0: a drilled day replaces the daily trend with its 24-hour
4207
+ // histogram. A stale selection (day gone / no hours) silently falls back to
4208
+ // the daily trend instead of charting a fabricated hour series.
4209
+ const drillPoint =
4210
+ typeof state.chartDay === 'string' && state.chartDay !== ''
4211
+ ? (points.find((point) => point.day === state.chartDay) ?? null)
4212
+ : null
4213
+ const drillHours = drillPoint === null ? null : chartHoursOfPoint(drillPoint)
4214
+ if (drillPoint !== null && drillHours !== null) return buildHourChart(state, drillPoint, drillHours, t)
4215
+ let peak = points[0]
4216
+ for (const point of points) {
4217
+ if (point.total > peak.total) peak = point
4218
+ }
4219
+ const body = buildChartPlot(state, points, {
4220
+ bar: (point, index, meta) => buildDailyChartBar(state, t, point, meta),
4221
+ xLabel: (point) => ({
4222
+ text: chartDayLabel(point.day),
4223
+ attrs: { 'data-chart-axis-day': point.day },
4224
+ }),
4225
+ readout: (hovered, share) =>
4226
+ t('statsChartReadout', {
4227
+ date: hovered.day,
4228
+ value: formatTokensPrecise(hovered.total),
4229
+ share: String(share),
4230
+ }),
4231
+ })
4232
+ return h(
4233
+ 'div',
4234
+ { key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true' },
4235
+ body,
3891
4236
  h(
3892
4237
  'div',
3893
4238
  { key: 'summary', className: 'dsh-tokenlens-chart-summary' },
@@ -4086,6 +4431,87 @@ window.__ModuleLoader__.load({
4086
4431
  return t('statsLast30Days')
4087
4432
  }
4088
4433
 
4434
+ /**
4435
+ * The 明细 donut windows in display order (v1.2.0): 今日 → 最近7天 →
4436
+ * 最近X天/最近30天 → 总计. Old Hosts omit the 7-day / second windows, so their
4437
+ * pills are hidden (never disabled ghosts of a window that cannot exist).
4438
+ */
4439
+ function detailsWindowChoices(ctx, t) {
4440
+ const choices = [{ key: 'today', label: t('statsTodayCol') }]
4441
+ if (ctx.hasWeek) choices.push({ key: 'last7Days', label: t('statsLast7Days') })
4442
+ if (ctx.hasMonth) choices.push({ key: 'second', label: detailsSecondLabel(ctx, t) })
4443
+ choices.push({ key: 'total', label: t('statsAllTimeCol') })
4444
+ return choices
4445
+ }
4446
+
4447
+ /**
4448
+ * The active 明细 donut window (v1.2.1): the stored choice while that window
4449
+ * still exists, else 总计. Since v1.2.1 the choice is also persisted to the
4450
+ * plugin settings (the startup bootstrap loads it back), so reopening the
4451
+ * dialog / restarting DSH restores it.
4452
+ */
4453
+ function detailsWindowOf(state, ctx) {
4454
+ const key = typeof state.detailsWindow === 'string' ? state.detailsWindow : 'total'
4455
+ if (key === 'today') return 'today'
4456
+ if (key === 'last7Days') return ctx.hasWeek ? 'last7Days' : 'total'
4457
+ if (key === 'second') return ctx.hasMonth ? 'second' : 'total'
4458
+ return 'total'
4459
+ }
4460
+
4461
+ /**
4462
+ * The bucket of one 明细 row for a donut window (v1.2.0). Harness/provider
4463
+ * rows carry the four stats windows directly; model rows use the caller's
4464
+ * second-window accessor (`secondModelOf`) for the effective-window column.
4465
+ */
4466
+ function detailsWindowBucketOf(windowKey, buckets, secondBucketOf) {
4467
+ if (buckets === null || buckets === undefined) return null
4468
+ if (windowKey === 'today') return buckets.today ?? null
4469
+ if (windowKey === 'last7Days') return buckets.last7Days ?? null
4470
+ if (windowKey === 'second') {
4471
+ return typeof secondBucketOf === 'function' ? secondBucketOf(buckets) : buckets.last30Days ?? null
4472
+ }
4473
+ return buckets.allTime ?? null
4474
+ }
4475
+
4476
+ /** The token total of one 明细 row for a donut window (v1.2.0). */
4477
+ function detailsWindowTotalOf(windowKey, buckets, secondBucketOf) {
4478
+ const bucket = detailsWindowBucketOf(windowKey, buckets, secondBucketOf)
4479
+ return bucket === null ? 0 : bucket.total ?? 0
4480
+ }
4481
+
4482
+ /** The window pill row under the 明细 donut title (v1.2.0; saved since v1.2.1). */
4483
+ function buildDetailsWindowSwitch(state, ctx, t) {
4484
+ const active = detailsWindowOf(state, ctx)
4485
+ const option = (key, label) =>
4486
+ h(
4487
+ 'button',
4488
+ {
4489
+ key,
4490
+ type: 'button',
4491
+ className: 'dsh-tokenlens-scope-option',
4492
+ 'data-details-window': key,
4493
+ 'data-active': active === key ? 'true' : 'false',
4494
+ 'aria-pressed': active === key ? 'true' : 'false',
4495
+ onClick: () => {
4496
+ if (active !== key) {
4497
+ store.set({ detailsWindow: key })
4498
+ persistDetailsWindow()
4499
+ }
4500
+ },
4501
+ },
4502
+ label,
4503
+ )
4504
+ return h(
4505
+ 'div',
4506
+ { key: 'details-window', className: 'dsh-tokenlens-details-window', 'data-details-window-switch': 'true' },
4507
+ h(
4508
+ 'div',
4509
+ { key: 'scope', className: 'dsh-tokenlens-scope', role: 'group', 'aria-label': t('statsWindowLabel') },
4510
+ detailsWindowChoices(ctx, t).map((choice) => option(choice.key, choice.label)),
4511
+ ),
4512
+ )
4513
+ }
4514
+
4089
4515
  /**
4090
4516
  * The compact 明细 table columns of one group (v0.30.2). Harness/provider
4091
4517
  * rows start at 今日; the all-harness top-model table starts at the
@@ -4758,10 +5184,18 @@ window.__ModuleLoader__.load({
4758
5184
  )
4759
5185
  }
4760
5186
 
4761
- /** The 明细 column in chart mode: donut share charts + ranked legends. */
5187
+ /**
5188
+ * The 明细 column in chart mode: donut share charts + ranked legends.
5189
+ *
5190
+ * v1.2.0: a 今日 / 最近7天 / 最近X天 / 总计 window switch under the title picks
5191
+ * which stats window the three donuts aggregate (default 总计). The pills of
5192
+ * windows an old Host does not report are hidden; the state lives in the store
5193
+ * and, since v1.2.1, is persisted to the plugin settings.
5194
+ */
4762
5195
  function buildStatsDetailsCharts(state, ctx, t) {
4763
- const { stats, hasMonth } = ctx
5196
+ const { stats } = ctx
4764
5197
  if (stats === null) return null
5198
+ const windowKey = detailsWindowOf(state, ctx)
4765
5199
  const children = [
4766
5200
  h(
4767
5201
  'div',
@@ -4773,17 +5207,18 @@ window.__ModuleLoader__.load({
4773
5207
  h('span', { key: 'label' }, t('statsDetailsTitle')),
4774
5208
  ),
4775
5209
  ),
5210
+ buildDetailsWindowSwitch(state, ctx, t),
4776
5211
  ]
5212
+ const groups = []
4777
5213
  const harnessRows = scopedHarnessRows(ctx).map((row) => ({
4778
5214
  label: row.label,
4779
- value: ctx.secondBucketOf(row.buckets)?.total ?? 0,
5215
+ value: detailsWindowTotalOf(windowKey, row.buckets, ctx.secondBucketOf),
4780
5216
  }))
4781
- const groups = []
4782
5217
  const harnessChart = buildBreakdownDonutGroup('harness-chart', t('statsByHarness'), 'list', harnessRows, t)
4783
5218
  if (harnessChart !== null) groups.push(harnessChart)
4784
5219
  const providerRows = stats.providers.map((provider) => ({
4785
5220
  label: provider.label,
4786
- value: ctx.secondBucketOf(provider)?.total ?? 0,
5221
+ value: detailsWindowTotalOf(windowKey, provider, ctx.secondBucketOf),
4787
5222
  }))
4788
5223
  const providerChart = buildBreakdownDonutGroup('provider-chart', t('statsByProvider'), 'server', providerRows, t)
4789
5224
  if (providerChart !== null) groups.push(providerChart)
@@ -4792,18 +5227,25 @@ window.__ModuleLoader__.load({
4792
5227
  // models from other providers silently disappear from the donut (v0.27.1).
4793
5228
  const modelRows = stats.models.map((model) => ({
4794
5229
  label: model.modelId,
4795
- value: ctx.secondModelOf(model)?.total ?? 0,
5230
+ value: detailsWindowTotalOf(windowKey, model, ctx.secondModelOf),
4796
5231
  }))
4797
5232
  const modelChart = buildBreakdownDonutGroup('model-chart', t('statsTopModels'), 'cube', modelRows, t)
4798
5233
  if (modelChart !== null) groups.push(modelChart)
4799
5234
  if (groups.length > 0) {
4800
5235
  children.push(h('div', { key: 'charts', className: 'dsh-tokenlens-details-charts-grid' }, groups))
4801
- }
4802
- if (children.length === 1) {
5236
+ } else {
5237
+ // An all-zero window renders no donut at all: keep the readable hint.
4803
5238
  children.push(h('div', { key: 'empty', className: 'dsh-tokenlens-hint' }, t('statsEmpty')))
4804
5239
  }
4805
- void hasMonth
4806
- return h('div', { key: 'details', className: 'dsh-tokenlens-section dsh-tokenlens-details' }, children)
5240
+ return h(
5241
+ 'div',
5242
+ {
5243
+ key: 'details',
5244
+ className: 'dsh-tokenlens-section dsh-tokenlens-details',
5245
+ 'data-details-window': windowKey,
5246
+ },
5247
+ children,
5248
+ )
4807
5249
  }
4808
5250
 
4809
5251
  function buildStatsDetails(state, ctx, t) {