dsh-usage-badge 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -32,7 +32,9 @@ dsh plugin --profile web remove dsh-usage-badge
32
32
 
33
33
  弹窗有两个页签:
34
34
 
35
- - **用量** —— 四个时间区间、按 provider 筛选、金额/请求数/Token/缓存命中率四项合计,以及一张四序列折线图(金额、token 总量、请求数、缓存命中率)。鼠标移到图上显示该点的四项数值和参考线。
35
+ - **用量** —— 四个时间区间(`24 小时` / `近 7 日` / `近 30 日` / `近 12 个月`)、按 provider 筛选、金额/请求数/Token/缓存命中率四项合计,以及一张四序列折线图(金额、token 总量、请求数、缓存命中率)。鼠标移到图上显示该点的四项数值和参考线。
36
+
37
+ 区间按**自然日 / 自然月**切分:`近 7 日` 就是最近 7 个连续自然日,没有用量的那天保留自己的空槽位,不会把窗口拉长成 8 天。
36
38
  - **单价配置** —— 官方定价、法定节假日日历、当前生效单价。
37
39
 
38
40
  ## 单价:只有一个来源
package/lib/client.js CHANGED
@@ -173,6 +173,46 @@ window.__ModuleLoader__.load({
173
173
  return out
174
174
  }
175
175
 
176
+ const pad2 = (value) => String(value).padStart(2, '0')
177
+
178
+ /** The local `YYYY-MM-DD` key of a Date. */
179
+ const dayKeyOf = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
180
+
181
+ /**
182
+ * The last `count` calendar days ending at `endKey`, oldest first.
183
+ *
184
+ * Enumerated from the calendar, not from the days that happen to have usage: the
185
+ * host only reports days with requests, so taking "the last 7 of those" stretches
186
+ * the window to whatever 7 days last had data and hides the gaps instead of
187
+ * showing them. A quiet Saturday is still a day.
188
+ */
189
+ function lastDays(endKey, count) {
190
+ const [year, month, day] = endKey.split('-').map(Number)
191
+ const cursor = new Date(year, month - 1, day)
192
+ const out = []
193
+ for (let index = 0; index < count; index++) {
194
+ out.unshift(dayKeyOf(cursor))
195
+ cursor.setDate(cursor.getDate() - 1)
196
+ }
197
+ return out
198
+ }
199
+
200
+ /** The last `count` calendar months ending at `endKey`'s month, oldest first. */
201
+ function lastMonths(endKey, count) {
202
+ const [year, month] = endKey.split('-').map(Number)
203
+ const out = []
204
+ for (let index = count - 1; index >= 0; index--) {
205
+ const cursor = new Date(year, month - 1 - index, 1)
206
+ out.push(`${cursor.getFullYear()}-${pad2(cursor.getMonth() + 1)}`)
207
+ }
208
+ return out
209
+ }
210
+
211
+ /** The day a snapshot is anchored on: the host's own "today". */
212
+ function anchorDate(snapshot) {
213
+ return snapshot.badge?.date ?? snapshot.today?.date ?? snapshot.days?.[0]?.date ?? ''
214
+ }
215
+
176
216
  /**
177
217
  * Build the chart points and totals for one range under one provider filter.
178
218
  *
@@ -207,30 +247,35 @@ window.__ModuleLoader__.load({
207
247
  }
208
248
 
209
249
  if (range === '12m') {
210
- const months = new Map()
250
+ const byMonth = new Map()
211
251
  for (const day of snapshot.days) {
212
252
  const key = day.date.slice(0, 7)
213
- if (!months.has(key)) months.set(key, [])
214
- months.get(key).push(entryTotals(day, provider))
253
+ if (!byMonth.has(key)) byMonth.set(key, [])
254
+ byMonth.get(key).push(entryTotals(day, provider))
215
255
  }
216
- const keys = [...months.keys()].sort().slice(-12)
256
+ // Twelve calendar months, so a month with no usage keeps its empty slot
257
+ // instead of quietly pushing an older month into the window.
217
258
  return {
218
- points: keys.map((key) => point(key.slice(2), key, sumTotals(months.get(key)))),
259
+ points: lastMonths(anchorDate(snapshot), 12).map((key) =>
260
+ point(key.slice(2), key, sumTotals(byMonth.get(key) ?? [])),
261
+ ),
219
262
  marks: false,
220
263
  }
221
264
  }
222
265
 
223
266
  const count = range === '7d' ? 7 : 30
224
- const slice = snapshot.days.slice(0, count).reverse()
267
+ const byDate = new Map(snapshot.days.map((day) => [day.date, day]))
225
268
  return {
226
- points: slice.map((day) =>
227
- point(
228
- day.date.slice(5),
229
- `${day.date}${day.dayName ? ` ${day.dayName}` : ''}`,
269
+ points: lastDays(anchorDate(snapshot), count).map((date) => {
270
+ const day = byDate.get(date)
271
+ if (!day) return point(date.slice(5), `${date}(无用量)`, EMPTY_TOTALS)
272
+ return point(
273
+ date.slice(5),
274
+ `${date}${day.dayName ? ` ${day.dayName}` : ''}`,
230
275
  entryTotals(day, provider),
231
276
  day.dayClass,
232
- ),
233
- ),
277
+ )
278
+ }),
234
279
  marks: true,
235
280
  }
236
281
  }
@@ -469,8 +514,8 @@ window.__ModuleLoader__.load({
469
514
  }
470
515
 
471
516
  /** The 用量 panel: ranges, provider filter, totals and the chart. */
472
- function UsagePanel({ snapshot }) {
473
- const [range, setRange] = React.useState('24h')
517
+ function UsagePanel({ snapshot, initialRange = '24h' }) {
518
+ const [range, setRange] = React.useState(initialRange)
474
519
  const [provider, setProvider] = React.useState('')
475
520
  const providers = snapshot.today.providers || []
476
521
  const series = buildSeries(snapshot, range, provider)
@@ -544,6 +589,17 @@ window.__ModuleLoader__.load({
544
589
  )
545
590
  }
546
591
 
592
+ /**
593
+ * The last successful 单价配置 payloads, kept for the life of the page.
594
+ *
595
+ * Switching tabs unmounts this panel, so without a cache every return to the tab
596
+ * starts from the loading placeholder and swaps its contents in a moment later —
597
+ * a visible flicker. Holding the last answer lets the panel render it straight
598
+ * away and refresh behind it.
599
+ */
600
+ let cachedConfig = null
601
+ let cachedOfficial = null
602
+
547
603
  /**
548
604
  * The 单价配置 panel.
549
605
  *
@@ -554,32 +610,38 @@ window.__ModuleLoader__.load({
554
610
  */
555
611
  function ConfigPanel({ snapshot, reload, initialConfig = null, initialOfficial = null }) {
556
612
  const [source, setSource] = React.useState('zh-cn')
557
- const [official, setOfficial] = React.useState(initialOfficial)
558
- const [officialState, setOfficialState] = React.useState(initialOfficial ? 'ready' : 'loading')
559
- const [applyState, setApplyState] = React.useState('idle')
560
- const [config, setConfig] = React.useState(initialConfig)
561
-
562
- const loadOfficial = React.useCallback(
563
- async (which) => {
564
- setOfficialState('loading')
565
- try {
566
- const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${which}`, { cache: 'no-store' })
567
- const body = await response.json()
568
- if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
569
- setOfficial(body)
570
- setOfficialState('ready')
571
- } catch (error) {
572
- setOfficial(null)
573
- setOfficialState(`failed: ${error?.message ?? error}`)
574
- }
575
- },
576
- [],
613
+ const [official, setOfficial] = React.useState(initialOfficial ?? cachedOfficial)
614
+ const [officialState, setOfficialState] = React.useState(
615
+ (initialOfficial ?? cachedOfficial) ? 'ready' : 'loading',
577
616
  )
617
+ const [applyState, setApplyState] = React.useState('idle')
618
+ const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
619
+
620
+ const loadOfficial = React.useCallback(async (which) => {
621
+ // Only show the placeholder when there is nothing to show: flipping to
622
+ // 'loading' with a table already on screen blanks it for a frame.
623
+ if (!cachedOfficial) setOfficialState('loading')
624
+ try {
625
+ const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${which}`, { cache: 'no-store' })
626
+ const body = await response.json()
627
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
628
+ cachedOfficial = body
629
+ setOfficial(body)
630
+ setOfficialState('ready')
631
+ } catch (error) {
632
+ // A failed refresh keeps the last good list on screen; the warning says why.
633
+ if (!cachedOfficial) setOfficial(null)
634
+ setOfficialState(`failed: ${error?.message ?? error}`)
635
+ }
636
+ }, [])
578
637
 
579
638
  const loadConfig = React.useCallback(async () => {
580
639
  try {
581
640
  const response = await fetch('/usage-badge/config', { cache: 'no-store' })
582
- if (response.ok) setConfig(await response.json())
641
+ if (response.ok) {
642
+ cachedConfig = await response.json()
643
+ setConfig(cachedConfig)
644
+ }
583
645
  } catch {
584
646
  // The effective config is informative here; a failure leaves the rest of
585
647
  // the panel usable.
@@ -850,7 +912,7 @@ window.__ModuleLoader__.load({
850
912
  )
851
913
  }
852
914
 
853
- function UsageDialog({ snapshot, onClose, reload, initialTab = 'usage', initialConfig = null, initialOfficial = null }) {
915
+ function UsageDialog({ snapshot, onClose, reload, initialTab = 'usage', initialConfig = null, initialOfficial = null, initialRange = '24h' }) {
854
916
  const [tab, setTab] = React.useState(initialTab)
855
917
  const chip = bandChip(snapshot)
856
918
 
@@ -893,10 +955,10 @@ window.__ModuleLoader__.load({
893
955
  ),
894
956
  ),
895
957
  ),
896
- // Only the active panel is mounted, so returning to 单价配置 re-fetches
897
- // the published list — which is the behavior that was asked for.
958
+ // Only the active panel is mounted, so returning to 单价配置 re-fetches the
959
+ // published list — from the cache it renders at once, then refreshes.
898
960
  tab === 'usage'
899
- ? h(UsagePanel, { snapshot })
961
+ ? h(UsagePanel, { snapshot, initialRange })
900
962
  : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial }),
901
963
  ),
902
964
  )
@@ -913,7 +975,8 @@ window.__ModuleLoader__.load({
913
975
  * seat: the full row while expanded, a 36px square on the collapsed rail.
914
976
  *
915
977
  * @param {{wide?: boolean, initialSnapshot?: object|null, initialOpen?: boolean,
916
- * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null}} props
978
+ * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null,
979
+ * initialRange?: string}} props
917
980
  */
918
981
  function UsageBadge({
919
982
  wide = true,
@@ -922,6 +985,7 @@ window.__ModuleLoader__.load({
922
985
  initialTab = 'usage',
923
986
  initialConfig = null,
924
987
  initialOfficial = null,
988
+ initialRange = '24h',
925
989
  } = {}) {
926
990
  const [snapshot, setSnapshot] = React.useState(initialSnapshot)
927
991
  const [failed, setFailed] = React.useState(false)
@@ -972,7 +1036,7 @@ window.__ModuleLoader__.load({
972
1036
  h('span', { className: 'dub-pill-amount' }, text),
973
1037
  ),
974
1038
  open && snapshot
975
- ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial })
1039
+ ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange })
976
1040
  : null,
977
1041
  )
978
1042
  }
@@ -91,16 +91,28 @@ function parseAmount(text) {
91
91
  function toLocalRanges(ranges, sourceOffsetMinutes, localOffsetMinutes) {
92
92
  const delta = localOffsetMinutes - sourceOffsetMinutes
93
93
  const round = (value) => Math.round(value * 100) / 100
94
+ const wrap = (minutes) => ((minutes % 1440) + 1440) % 1440
94
95
  const out = []
95
96
  for (const [start, end] of ranges) {
96
- const startMinutes = start * 60 + delta
97
- const endMinutes = end * 60 + delta
97
+ let startMinutes = start * 60 + delta
98
+ let endMinutes = end * 60 + delta
99
+
100
+ // A window pushed entirely out of the day wraps as a whole. Only a window that
101
+ // straddles midnight splits in two — splitting an out-of-range one instead
102
+ // produced nonsense ranges (01:00–04:00 UTC in a UTC-5 host came out as
103
+ // [0,23], i.e. almost the entire day at peak rates).
104
+ if (endMinutes <= 0) {
105
+ startMinutes += 1440
106
+ endMinutes += 1440
107
+ } else if (startMinutes >= 1440) {
108
+ startMinutes -= 1440
109
+ endMinutes -= 1440
110
+ }
111
+
98
112
  if (startMinutes >= 0 && endMinutes <= 1440) {
99
113
  out.push([round(startMinutes / 60), round(endMinutes / 60)])
100
114
  continue
101
115
  }
102
- // The shifted window crosses midnight, so it becomes two ranges.
103
- const wrap = (minutes) => ((minutes % 1440) + 1440) % 1440
104
116
  out.push([round(wrap(startMinutes) / 60), 24])
105
117
  out.push([0, round(wrap(endMinutes) / 60)])
106
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month charts, official DeepSeek pricing and Chinese public holidays.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,7 +39,7 @@
39
39
  "docs"
40
40
  ],
41
41
  "scripts": {
42
- "test": "node test/manifest.verify.mjs && node test/pricing.verify.mjs && node test/holidays.verify.mjs && node test/official-pricing.verify.mjs && node test/client.render.mjs",
42
+ "test": "node test/all.mjs",
43
43
  "test:smoke": "node test/smoke.mjs",
44
44
  "prepublishOnly": "npm test"
45
45
  },