dsh-simple-usage-info 0.1.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/lib/client.js ADDED
@@ -0,0 +1,620 @@
1
+ /**
2
+ * dsh-simple-usage-info — browser half.
3
+ *
4
+ * Registered into the `conversation.composer.dock` slot: the ambient row the
5
+ * composer renders *below* the message input. The bar shows the DeepSeek account
6
+ * balance plus a peak / off-peak pill, and clicking it opens a popover with the
7
+ * full breakdown, laid out as label/value rows the way the shipped context meter
8
+ * does.
9
+ *
10
+ * The billing window is evaluated HERE, in the browser, from the serializable
11
+ * schedule the host sends:
12
+ *
13
+ * - the instant is skew-corrected against the host's clock, so a wrong browser
14
+ * clock cannot mis-report the price;
15
+ * - the rule is re-evaluated on a timer, so the badge flips on time instead of
16
+ * waiting for the next poll;
17
+ * - the windows are rendered in the user's own timezone, which only the
18
+ * browser knows.
19
+ *
20
+ * `windowAt` and `nextWindowChange` mirror lib/pricing.js. A preflight sweeps
21
+ * both implementations against each other so they cannot drift.
22
+ *
23
+ * This file is authored directly in the module-loader bundle format the DSH web
24
+ * client serves from a package's `./client` export, so the plugin needs no
25
+ * bundler:
26
+ *
27
+ * window.__ModuleLoader__.load({ id, factory: (require) => exports })
28
+ *
29
+ * `react`, `react-dom`, and `@deepseek-ai/dsh-client-ui-primitives` all come from
30
+ * the client's frozen platform module seed, so the plugin declares no
31
+ * `dsh.client.external` request.
32
+ */
33
+ window.__ModuleLoader__.load({
34
+ id: 'dsh-simple-usage-info',
35
+ factory: (require) => {
36
+ var module = { exports: {} }
37
+ var exports = module.exports
38
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
39
+
40
+ const React = require('react')
41
+ const ReactDOM = require('react-dom')
42
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
43
+
44
+ /** The host half's route; keep in sync with `BALANCE_PATH` in lib/index.js. */
45
+ const PATH = '/usage/balance'
46
+ /** The dock cell key this plugin owns. */
47
+ const ID = 'usage-info'
48
+ /** Re-read cadence. The host caches for a minute, so this is cheap. */
49
+ const POLL_MS = 60000
50
+ /** How often the clock-derived badge re-evaluates between polls. */
51
+ const TICK_MS = 20000
52
+
53
+ const PEAK = 'peak'
54
+ const OFF_PEAK = 'off-peak'
55
+
56
+ // ------------------------------------------------------------------ styles
57
+
58
+ /**
59
+ * Dock entries render in a centered flex row, so the bar is one inline item.
60
+ *
61
+ * `order: 1` is deliberate. ui-conversation renders the dock as
62
+ * `[conversation.composer.dock entries..., ContextMeter]` — the context
63
+ * percentage is hardcoded AFTER this slot, so no order value inside the slot
64
+ * can place a bar past it. Flex `order` sorts across the whole row instead:
65
+ * the ContextMeter root (`.JObwrW_root`) declares no `order`, so it keeps the
66
+ * default 0 and this item lands at the very end of the row.
67
+ */
68
+ /**
69
+ * Every rule lives in a stylesheet, never inline.
70
+ *
71
+ * `useAnchoredPosition` returns ONLY `{left, top}`, and that object replaces
72
+ * the panel's whole inline `style` — so a design carried inline disappears the
73
+ * instant the panel is positioned. The shipped context meter avoids this the
74
+ * same way: the panel wears a class, and the inline style is just coordinates.
75
+ */
76
+ const PANEL_CSS = `
77
+ .dsu-panel{position:fixed;z-index:1100;box-sizing:border-box;width:min(320px,100vw - 24px);padding:12px;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;border:0;font-size:12px;line-height:20px}
78
+ .dsu-header{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
79
+ .dsu-title{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-weight:600}
80
+ .dsu-headline{color:var(--dsw-alias-label-primary);font-weight:600;font-variant-numeric:tabular-nums}
81
+ .dsu-divider{height:1px;background:var(--dsw-alias-border-l1);margin:8px 0}
82
+ .dsu-group-title{color:var(--dsw-alias-label-caption);font-size:11px;margin-top:2px}
83
+ .dsu-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
84
+ .dsu-label{color:var(--dsw-alias-label-tertiary);flex:none}
85
+ .dsu-value{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right;min-width:0;overflow-wrap:anywhere}
86
+ .dsu-value-error{color:var(--dsw-alias-state-error-primary)}
87
+ .dsu-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;color:var(--dsw-alias-label-tertiary)}
88
+ .dsu-refresh{display:inline-flex;align-items:center;justify-content:center;background:0 0;border:none;padding:2px;border-radius:var(--dsw-radius-sm);cursor:pointer;color:var(--dsw-alias-label-tertiary)}
89
+ .dsu-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
90
+ .dsu-refresh svg{display:block;transition:transform .15s ease}
91
+ .dsu-refresh:active svg{transform:rotate(-90deg)}
92
+ .dsu-bar{order:1;display:inline-flex;align-items:center;gap:6px;padding:1px 8px;border-radius:var(--dsw-radius-sm);font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;cursor:pointer;user-select:none;background:0 0;border:none}
93
+ .dsu-bar:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
94
+ .dsu-dot{width:6px;height:6px;border-radius:50%;flex:none}
95
+ .dsu-dot-ok{background:var(--dsw-alias-state-success-primary)}
96
+ .dsu-dot-low{background:var(--dsw-alias-state-error-primary)}
97
+ .dsu-dot-unknown{background:var(--dsw-alias-label-quaternary)}
98
+ .dsu-pill{padding:0 6px;border-radius:999px;border:1px solid currentColor;font-size:11px;line-height:15px;font-weight:500;flex:none}
99
+ .dsu-pill-off{color:var(--dsw-alias-state-success-primary)}
100
+ .dsu-pill-peak{color:var(--dsw-alias-state-error-primary)}
101
+ `
102
+
103
+ const STYLE_TAG = 'dsh-simple-usage-info/panel.css'
104
+
105
+ /** Install the stylesheet once, tagged so the module system can withdraw it. */
106
+ function installStyles() {
107
+ if (typeof document === 'undefined') return
108
+ if (document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) return
109
+ const tag = document.createElement('style')
110
+ tag.dataset.plugin = 'dsh-simple-usage-info'
111
+ tag.dataset.pluginCss = STYLE_TAG
112
+ tag.textContent = PANEL_CSS
113
+ document.head.appendChild(tag)
114
+ }
115
+
116
+ installStyles()
117
+
118
+ // ---------------------------------------------------------------- schedule
119
+ // Mirrors lib/pricing.js. Keep the two in step; the preflight sweeps them.
120
+
121
+ /** `YYYY-MM-DD` for one instant's UTC date. */
122
+ function utcDayKey(instant) {
123
+ const date = new Date(instant)
124
+ const pad = (value) => String(value).padStart(2, '0')
125
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
126
+ }
127
+
128
+ /**
129
+ * Which window one instant falls in, per the published policy: peak only in
130
+ * the listed UTC windows on a listed weekday, and never on a Chinese public
131
+ * holiday.
132
+ * @param schedule - the serialized schedule from the host.
133
+ * @param instant - epoch milliseconds.
134
+ * @returns the window and why it applies.
135
+ */
136
+ function windowAt(schedule, instant) {
137
+ const date = new Date(instant)
138
+ const key = utcDayKey(instant)
139
+ if (schedule.holidays.indexOf(key) >= 0) return { window: OFF_PEAK, reason: 'holiday' }
140
+ const makesUp = schedule.makeupWorkdaysArePeak === true && schedule.makeupWorkdays.indexOf(key) >= 0
141
+ if (!makesUp && schedule.peakDays.indexOf(date.getUTCDay()) < 0) return { window: OFF_PEAK, reason: 'non-peak-day' }
142
+ const minutes = date.getUTCHours() * 60 + date.getUTCMinutes()
143
+ for (const window of schedule.peakWindows) {
144
+ if (minutes >= window.start && minutes < window.end) return { window: PEAK, reason: 'peak-hours' }
145
+ }
146
+ return { window: OFF_PEAK, reason: 'outside-peak-hours' }
147
+ }
148
+
149
+ /**
150
+ * The first instant after `instant` whose window differs. Same boundary scan
151
+ * as the host: UTC midnights plus every window edge, eight days out.
152
+ * @param schedule - the serialized schedule.
153
+ * @param instant - epoch milliseconds.
154
+ * @returns the next boundary, or `undefined`.
155
+ */
156
+ function nextWindowChange(schedule, instant) {
157
+ const base = new Date(instant)
158
+ const year = base.getUTCFullYear()
159
+ const month = base.getUTCMonth()
160
+ const day = base.getUTCDate()
161
+ const current = windowAt(schedule, instant).window
162
+ const candidates = []
163
+ for (let offset = 0; offset <= 8; offset += 1) {
164
+ const midnight = Date.UTC(year, month, day + offset, 0, 0, 0, 0)
165
+ candidates.push(midnight)
166
+ for (const window of schedule.peakWindows) {
167
+ candidates.push(midnight + window.start * 60000)
168
+ candidates.push(midnight + window.end * 60000)
169
+ }
170
+ }
171
+ for (const candidate of candidates.filter((value) => value > instant).sort((left, right) => left - right)) {
172
+ const state = windowAt(schedule, candidate)
173
+ if (state.window !== current) return { at: candidate, window: state.window }
174
+ }
175
+ return { at: undefined, window: undefined }
176
+ }
177
+
178
+ /**
179
+ * The complete billing reading for one instant.
180
+ * @param schedule - the serialized schedule.
181
+ * @param instant - epoch milliseconds.
182
+ * @returns window, reason, discount, and the next transition.
183
+ */
184
+ function readWindow(schedule, instant) {
185
+ const state = windowAt(schedule, instant)
186
+ const next = nextWindowChange(schedule, instant)
187
+ return {
188
+ window: state.window,
189
+ reason: state.reason,
190
+ discountPercent: state.window === OFF_PEAK ? schedule.offPeakDiscountPercent : 0,
191
+ nextChangeAt: next.at,
192
+ nextWindow: next.window
193
+ }
194
+ }
195
+
196
+ // -------------------------------------------------------------- formatting
197
+
198
+ /** The viewer's own timezone, which only the browser knows. */
199
+ const ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone
200
+
201
+ const TIME_FORMAT = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
202
+ const STAMP_FORMAT = new Intl.DateTimeFormat(undefined, {
203
+ weekday: 'short',
204
+ hour: '2-digit',
205
+ minute: '2-digit',
206
+ hourCycle: 'h23'
207
+ })
208
+
209
+ /** Why the window applies, in words. */
210
+ const REASONS = {
211
+ holiday: 'Chinese public holiday',
212
+ 'non-peak-day': 'not a peak weekday',
213
+ 'peak-hours': 'inside a peak window',
214
+ 'outside-peak-hours': 'outside the peak windows'
215
+ }
216
+
217
+ /** A rough, always-rounding-up countdown, so it never reads `0m` while pending. */
218
+ function countdown(target, now) {
219
+ if (typeof target !== 'number') return undefined
220
+ const delta = target - (typeof now === 'number' ? now : Date.now())
221
+ if (delta <= 0) return undefined
222
+ const minutes = Math.ceil(delta / 60000)
223
+ const hours = Math.floor(minutes / 60)
224
+ if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
225
+ if (hours > 0) return `${hours}h ${minutes % 60}m`
226
+ return `${minutes}m`
227
+ }
228
+
229
+ /**
230
+ * The schedule's peak windows as wall-clock times in a timezone. Offsets are
231
+ * read for the day of `instant`, so a DST shift shows correctly for today.
232
+ * @param schedule - the serialized schedule.
233
+ * @param instant - the reference instant.
234
+ * @param zone - an IANA zone; defaults to the viewer's own.
235
+ * @returns `09:00–12:00, 14:00–18:00`.
236
+ */
237
+ function localWindows(schedule, instant, zone) {
238
+ const format =
239
+ zone === undefined
240
+ ? TIME_FORMAT
241
+ : new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone })
242
+ const date = new Date(instant)
243
+ const midnight = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
244
+ return schedule.peakWindows
245
+ .map(
246
+ (window) =>
247
+ `${format.format(new Date(midnight + window.start * 60000))}–${format.format(new Date(midnight + window.end * 60000))}`
248
+ )
249
+ .join(', ')
250
+ }
251
+
252
+ /** The next transition as a wall-clock stamp in the viewer's timezone. */
253
+ function localStamp(instant) {
254
+ return typeof instant === 'number' ? STAMP_FORMAT.format(new Date(instant)) : undefined
255
+ }
256
+
257
+ /** Render one wallet as `¥110.00` / `$110.00` / `110.00 CHF`. */
258
+ function formatWallet(info) {
259
+ const amount = String(info?.total_balance ?? '')
260
+ const currency = String(info?.currency ?? '')
261
+ if (currency === 'CNY') return `¥${amount}`
262
+ if (currency === 'USD') return `$${amount}`
263
+ return currency === '' ? amount : `${amount} ${currency}`
264
+ }
265
+
266
+ /** A 6px status dot, green while the account can make API calls. */
267
+ function dot(available, key) {
268
+ const tone =
269
+ available === true ? 'dsu-dot-ok' : available === false ? 'dsu-dot-low' : 'dsu-dot-unknown'
270
+ return React.createElement('span', { key: key ?? 'dot', 'aria-hidden': true, className: `dsu-dot ${tone}` })
271
+ }
272
+
273
+ /**
274
+ * The dock pill. Deliberately just the window name — the discount, the
275
+ * reason, and every number live in the popover.
276
+ */
277
+ function priceBadge(pricing) {
278
+ if (pricing === undefined || pricing === null || pricing.window === undefined) return null
279
+ const offPeak = pricing.window === OFF_PEAK
280
+ return React.createElement(
281
+ 'span',
282
+ { key: 'window', className: `dsu-pill ${offPeak ? 'dsu-pill-off' : 'dsu-pill-peak'}` },
283
+ offPeak ? 'Off-peak' : 'Peak'
284
+ )
285
+ }
286
+
287
+ /**
288
+ * One line of text for the dock. The API reports every configured wallet,
289
+ * usually including a permanently zero one, so the bar shows the first
290
+ * funded wallet; the popover carries the full breakdown either way.
291
+ */
292
+ function summarize(state) {
293
+ if (state.status === 'loading') return 'DeepSeek …'
294
+ if (state.status === 'error') return 'DeepSeek unavailable'
295
+ const infos = state.payload?.balance?.balance_infos ?? []
296
+ if (infos.length === 0) return 'DeepSeek no balance'
297
+ const funded = infos.find((info) => Number(info?.total_balance ?? 0) > 0)
298
+ return `DeepSeek ${formatWallet(funded ?? infos[0])}`
299
+ }
300
+
301
+ /**
302
+ * The popover's contents as plain data, so they can be asserted without a DOM.
303
+ * @param payload - the host's balance branch, or undefined.
304
+ * @param pricing - the live billing reading.
305
+ * @param now - skew-corrected epoch milliseconds.
306
+ * @returns the headline, the label/value groups, and the footer.
307
+ */
308
+ function panelModel(payload, pricing, now) {
309
+ const infos = payload?.ok === true ? (payload.balance?.balance_infos ?? []) : []
310
+ const funded = infos.find((info) => Number(info?.total_balance ?? 0) > 0)
311
+ // The API lists a permanently zero wallet first (USD beside a funded CNY),
312
+ // so the funded wallet leads the panel. `sort` is stable, so the rest keep
313
+ // the API's order.
314
+ const ordered = [...infos].sort(
315
+ (left, right) => (Number(left?.total_balance ?? 0) > 0 ? 0 : 1) - (Number(right?.total_balance ?? 0) > 0 ? 0 : 1)
316
+ )
317
+
318
+ const groups = []
319
+ if (pricing !== undefined && pricing !== null && pricing.window !== undefined) {
320
+ const offPeak = pricing.window === OFF_PEAK
321
+ const rows = [
322
+ { label: 'Billing', value: offPeak ? 'Off-peak' : 'Peak' },
323
+ { label: 'Applies because', value: REASONS[pricing.reason] ?? String(pricing.reason) }
324
+ ]
325
+ const schedule = pricing.windows
326
+ if (schedule !== undefined) {
327
+ rows.push({ label: `Peak hours today (${ZONE})`, value: localWindows(schedule, now, undefined) })
328
+ }
329
+ const remaining = countdown(pricing.nextChangeAt, now)
330
+ rows.push({
331
+ label: 'Next switch',
332
+ value:
333
+ remaining === undefined
334
+ ? '—'
335
+ : `${pricing.nextWindow === PEAK ? 'peak' : 'off-peak'} in ${remaining} (${localStamp(pricing.nextChangeAt)})`
336
+ })
337
+ if (pricing.holidayCovered === false) {
338
+ rows.push({ label: 'Holidays', value: `not bundled for ${pricing.holidayYear}` })
339
+ }
340
+ groups.push({ title: 'Billing window', rows })
341
+ }
342
+
343
+ for (const info of ordered) {
344
+ groups.push({
345
+ title: String(info.currency ?? 'balance'),
346
+ rows: [
347
+ { label: 'Total', value: formatWallet(info) },
348
+ { label: 'Granted', value: formatWallet({ currency: info.currency, total_balance: info.granted_balance }) }
349
+ ]
350
+ })
351
+ }
352
+
353
+ const error =
354
+ payload?.ok === false ? String(payload.error?.message ?? payload.error?.code ?? 'balance unavailable') : undefined
355
+
356
+ return {
357
+ headline: funded !== undefined ? formatWallet(funded) : '—',
358
+ available: payload?.ok === true ? payload.balance?.is_available : undefined,
359
+ groups,
360
+ error,
361
+ footer:
362
+ typeof payload?.fetchedAt === 'number'
363
+ ? `Read at ${TIME_FORMAT.format(new Date(payload.fetchedAt))}`
364
+ : 'Not read yet'
365
+ }
366
+ }
367
+
368
+ /** One label/value row. */
369
+ function panelRow(row, index) {
370
+ return React.createElement('div', { key: `row-${index}`, className: 'dsu-row' }, [
371
+ React.createElement('span', { key: 'label', className: 'dsu-label' }, row.label),
372
+ React.createElement('span', { key: 'value', className: 'dsu-value' }, row.value)
373
+ ])
374
+ }
375
+
376
+ /** The refresh glyph: a stroked circular arrow, sized to the text line. */
377
+ function refreshIcon() {
378
+ return React.createElement(
379
+ 'svg',
380
+ {
381
+ key: 'glyph',
382
+ viewBox: '0 0 24 24',
383
+ width: 14,
384
+ height: 14,
385
+ fill: 'none',
386
+ stroke: 'currentColor',
387
+ strokeWidth: 2,
388
+ strokeLinecap: 'round',
389
+ strokeLinejoin: 'round',
390
+ 'aria-hidden': true,
391
+ focusable: false
392
+ },
393
+ [
394
+ React.createElement('polyline', { key: 'head', points: '23 4 23 10 17 10' }),
395
+ React.createElement('path', { key: 'arc', d: 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10' })
396
+ ]
397
+ )
398
+ }
399
+
400
+ /** The popover card, portaled to the body and positioned from its anchor. */
401
+ function panelElement(model, position, panelRef, onRefresh) {
402
+ const children = [
403
+ React.createElement('div', { key: 'header', className: 'dsu-header' }, [
404
+ React.createElement('span', { key: 'title', className: 'dsu-title' }, [
405
+ dot(model.available, 'dot'),
406
+ React.createElement('span', { key: 'text' }, 'DeepSeek account')
407
+ ]),
408
+ React.createElement('span', { key: 'headline', className: 'dsu-headline' }, model.headline)
409
+ ])
410
+ ]
411
+ model.groups.forEach((group, groupIndex) => {
412
+ children.push(React.createElement('div', { key: `divider-${groupIndex}`, className: 'dsu-divider' }))
413
+ children.push(
414
+ React.createElement('div', { key: `group-${groupIndex}` }, [
415
+ React.createElement('div', { key: 'title', className: 'dsu-group-title' }, group.title),
416
+ ...group.rows.map(panelRow)
417
+ ])
418
+ )
419
+ })
420
+ if (model.error !== undefined) {
421
+ children.push(
422
+ React.createElement('div', { key: 'error', className: 'dsu-row' }, [
423
+ React.createElement('span', { key: 'label', className: 'dsu-label' }, 'Balance'),
424
+ React.createElement('span', { key: 'value', className: 'dsu-value dsu-value-error' }, model.error)
425
+ ])
426
+ )
427
+ }
428
+ children.push(React.createElement('div', { key: 'footer-divider', className: 'dsu-divider' }))
429
+ children.push(
430
+ React.createElement('div', { key: 'footer', className: 'dsu-footer' }, [
431
+ React.createElement('span', { key: 'read' }, model.footer),
432
+ React.createElement(
433
+ 'button',
434
+ {
435
+ key: 'refresh',
436
+ type: 'button',
437
+ className: 'dsu-refresh',
438
+ onClick: onRefresh,
439
+ title: 'Refresh balance',
440
+ 'aria-label': 'Refresh balance'
441
+ },
442
+ refreshIcon()
443
+ )
444
+ ])
445
+ )
446
+ return React.createElement(
447
+ 'div',
448
+ {
449
+ ref: panelRef,
450
+ className: 'dsu-panel',
451
+ // The anchor hook returns ONLY coordinates, and this object replaces the
452
+ // whole inline style — so it must never be the carrier of the design.
453
+ style: position ?? { visibility: 'hidden', left: 0, top: 0 },
454
+ role: 'dialog',
455
+ 'aria-label': 'DeepSeek account and billing window'
456
+ },
457
+ children
458
+ )
459
+ }
460
+
461
+ /**
462
+ * The dock entry. Reads the host route on mount and once a minute after,
463
+ * re-evaluates the billing window every 20 s in between, and opens the
464
+ * popover on click.
465
+ * @returns the bar plus, while open, the portaled panel.
466
+ */
467
+ function UsageBar() {
468
+ const [state, setState] = React.useState({ status: 'loading' })
469
+ const [open, setOpen] = React.useState(false)
470
+ const [, setTick] = React.useState(0)
471
+ const alive = React.useRef(true)
472
+ const rootRef = React.useRef(null)
473
+ const panelRef = React.useRef(null)
474
+
475
+ const load = React.useCallback(async (force) => {
476
+ try {
477
+ const response = await fetch(force === true ? `${PATH}?refresh=1` : PATH, {
478
+ headers: { accept: 'application/json' },
479
+ credentials: 'same-origin'
480
+ })
481
+ const body = await response.json().catch(() => undefined)
482
+ if (alive.current !== true) return
483
+ // The host sends `pricing` in both branches, so the billing window
484
+ // stays on screen even when the balance read failed.
485
+ const pricing = body?.pricing
486
+ const skewMs = typeof pricing?.now === 'number' ? pricing.now - Date.now() : 0
487
+ if (body !== undefined && body.ok === true) setState({ status: 'ready', payload: body, pricing, skewMs })
488
+ else if (body !== undefined && body.ok === false)
489
+ setState({ status: 'error', message: String(body.error?.message ?? 'unknown error'), pricing, skewMs })
490
+ else setState({ status: 'error', message: `HTTP ${response.status}` })
491
+ } catch (error) {
492
+ if (alive.current === true)
493
+ setState((previous) => ({
494
+ status: 'error',
495
+ message: String(error?.message ?? error),
496
+ pricing: previous.pricing,
497
+ skewMs: previous.skewMs
498
+ }))
499
+ }
500
+ }, [])
501
+
502
+ React.useEffect(() => {
503
+ alive.current = true
504
+ load(false)
505
+ const poll = setInterval(() => load(false), POLL_MS)
506
+ const tick = setInterval(() => setTick((count) => count + 1), TICK_MS)
507
+ return () => {
508
+ alive.current = false
509
+ clearInterval(poll)
510
+ clearInterval(tick)
511
+ }
512
+ }, [load])
513
+
514
+ React.useEffect(() => {
515
+ if (open !== true) return undefined
516
+ const onKeyDown = (event) => {
517
+ if (event.key === 'Escape') setOpen(false)
518
+ }
519
+ document.addEventListener('keydown', onKeyDown)
520
+ return () => document.removeEventListener('keydown', onKeyDown)
521
+ }, [open])
522
+
523
+ const position = primitives.useAnchoredPosition({
524
+ open,
525
+ anchorRef: rootRef,
526
+ panelRef,
527
+ side: 'top',
528
+ gap: 8,
529
+ margin: 12
530
+ })
531
+ primitives.useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
532
+
533
+ // Billing time is the host's clock, corrected for this browser's skew, so a
534
+ // wrong local clock cannot mis-report the price.
535
+ const now = Date.now() + (state.skewMs ?? 0)
536
+ const host = state.pricing
537
+ const schedule = host?.windows
538
+ const pricing =
539
+ schedule !== undefined
540
+ ? {
541
+ ...readWindow(schedule, now),
542
+ schedule: host.schedule,
543
+ holidayYear: host.holidayYear,
544
+ holidayCovered: host.holidayCovered,
545
+ windows: schedule
546
+ }
547
+ : host
548
+
549
+ const available = state.status === 'ready' ? state.payload?.balance?.is_available : undefined
550
+ const bar = React.createElement(
551
+ 'span',
552
+ {
553
+ key: 'bar',
554
+ ref: rootRef,
555
+ role: 'button',
556
+ tabIndex: 0,
557
+ 'aria-haspopup': 'dialog',
558
+ 'aria-expanded': open,
559
+ title: 'DeepSeek balance and billing window',
560
+ onClick: () => setOpen((value) => value !== true),
561
+ onKeyDown: (event) => {
562
+ if (event.key === 'Enter' || event.key === ' ') {
563
+ event.preventDefault()
564
+ setOpen((value) => value !== true)
565
+ }
566
+ },
567
+ className: 'dsu-bar'
568
+ },
569
+ [dot(available), React.createElement('span', { key: 'text' }, summarize(state)), priceBadge(pricing)]
570
+ )
571
+
572
+ return React.createElement(React.Fragment, null, [
573
+ bar,
574
+ open === true
575
+ ? ReactDOM.createPortal(
576
+ panelElement(panelModel(state.payload, pricing, now), position, panelRef, () => load(true)),
577
+ document.body
578
+ )
579
+ : null
580
+ ])
581
+ }
582
+
583
+ /** Cordis services this browser plugin needs. */
584
+ const inject = ['slots']
585
+
586
+ /**
587
+ * Client plugin body: one entry in the composer dock.
588
+ * @param ctx - client root context.
589
+ */
590
+ function apply(ctx) {
591
+ ctx.slots.inject('conversation.composer.dock', () =>
592
+ ctx.slots.register(
593
+ {
594
+ name: 'conversation.composer.dock',
595
+ id: ID,
596
+ order: 50
597
+ },
598
+ UsageBar
599
+ )
600
+ )
601
+ }
602
+
603
+ exports.UsageBar = UsageBar
604
+ exports.apply = apply
605
+ exports.inject = inject
606
+ // Exported for the offline preflight; not part of the plugin contract.
607
+ exports.formatWallet = formatWallet
608
+ exports.summarize = summarize
609
+ exports.priceBadge = priceBadge
610
+ exports.panelModel = panelModel
611
+ exports.countdown = countdown
612
+ exports.windowAt = windowAt
613
+ exports.readWindow = readWindow
614
+ exports.localWindows = localWindows
615
+ exports.zone = ZONE
616
+ exports.panelElement = panelElement
617
+ exports.PANEL_CSS = PANEL_CSS
618
+ return module.exports
619
+ }
620
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * dsh-simple-usage-info — maintained holiday source types.
3
+ * @module dsh-simple-usage-info/holiday-source
4
+ */
5
+
6
+ /** Where the maintained data lives; `{year}` is substituted. */
7
+ export declare const DEFAULT_HOLIDAY_URL: string
8
+ /** How long one successful snapshot stays fresh. */
9
+ export declare const DEFAULT_TTL_MS: number
10
+ /** Remote request timeout. */
11
+ export declare const DEFAULT_TIMEOUT_MS: number
12
+
13
+ /** One merged, fetched holiday snapshot. */
14
+ export interface HolidaySnapshot {
15
+ /** Every statutory day off across the fetched years, sorted. */
16
+ readonly holidays: readonly string[]
17
+ /** Every 调休 makeup workday across the fetched years, sorted. */
18
+ readonly makeupWorkdays: readonly string[]
19
+ /** The years the snapshot actually covers. */
20
+ readonly years: readonly number[]
21
+ /** The State Council papers the data was scraped from. */
22
+ readonly papers: readonly string[]
23
+ /** When the snapshot was published. */
24
+ readonly fetchedAt: number
25
+ }
26
+
27
+ /** Injectable seams and configuration for {@link createHolidaySource}. */
28
+ export interface HolidaySourceOptions {
29
+ /** URL template with a `{year}` placeholder; empty disables fetching. */
30
+ readonly urlTemplate?: string
31
+ /** How long a snapshot stays fresh. */
32
+ readonly ttlMs?: number
33
+ /** Per-request timeout. */
34
+ readonly timeoutMs?: number
35
+ /** The fetch implementation; injectable for tests. */
36
+ readonly fetchImpl?: typeof fetch
37
+ /** The clock; injectable for tests. */
38
+ readonly now?: () => number
39
+ /** Where refresh failures are reported. */
40
+ readonly log?: (message: string) => void
41
+ }
42
+
43
+ /** A lazily-refreshed holiday source. */
44
+ export interface HolidaySource {
45
+ /** The current snapshot, or undefined before the first success. */
46
+ peek(): HolidaySnapshot | undefined
47
+ /** Whether a refresh is worth starting for these years. */
48
+ stale(years: readonly number[]): boolean
49
+ /** Fetch every requested year; never rejects. */
50
+ refresh(years: readonly number[]): Promise<HolidaySnapshot | undefined>
51
+ }
52
+
53
+ /** Build a lazily-refreshed holiday source. */
54
+ export declare function createHolidaySource(options?: HolidaySourceOptions): HolidaySource