@ychris12138/dsh-usage-stats 0.2.6

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,1531 @@
1
+ /**
2
+ * dsh-usage-stats — browser half.
3
+ *
4
+ * Hand-written `__ModuleLoader__` bundle (no build step): a sidebar footer
5
+ * action that opens a floating panel with provider balances, subscription
6
+ * quota windows, a Codex-style blue daily token-usage heatmap, per-day
7
+ * provider/model breakdowns, and cache hit rates. Data comes from the server
8
+ * half's loopback-only endpoints via same-origin fetch.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: "dsh-usage-stats",
12
+ factory: (require) => {
13
+ var module = { exports: {} };
14
+ var exports = module.exports;
15
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
16
+
17
+ let react = require("react");
18
+ let react_jsx_runtime = require("react/jsx-runtime");
19
+ let react_dom = require("react-dom");
20
+ let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
21
+
22
+ //#region css
23
+ const css = [
24
+ ".usg_layer{flex:none;align-items:center;width:100%;height:49px;margin:8px 0 0;display:flex;position:relative}",
25
+ ".usg_footerButtons{align-items:center;width:100%;display:flex}",
26
+ ".usg_badge{width:100%;height:49px;color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border:none;border-radius:12px;align-items:center;gap:8px;padding:0 8px 0 6px;font-family:inherit;font-size:14px;display:inline-flex;overflow:hidden}",
27
+ ".usg_badge:hover{background:var(--dsw-alias-interactive-bg-hover-solid)}",
28
+ ".usg_badge[data-active]{background:var(--dsw-alias-interactive-bg-hover)}",
29
+ ".usg_badgeLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}",
30
+ ".usg_badgeAmount{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none;font-size:12px;font-weight:600;line-height:16px}",
31
+ ".usg_badgeOk{color:var(--dsw-alias-state-success-primary)}",
32
+ ".usg_badgeBad{color:var(--dsw-alias-state-error-primary)}",
33
+ ".usg_badgeCount{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none;margin-left:auto;font-size:12px;line-height:16px}",
34
+ ".usg_layer.usg_rail{width:36px;height:36px;margin:0}",
35
+ ".usg_layer.usg_rail .usg_badge{border-radius:50%;justify-content:center;gap:0;width:36px;height:36px;padding:0}",
36
+ ".usg_layer.usg_rail .usg_footerButtons{flex-direction:column;gap:2px}",
37
+ ".usg_panel{z-index:100;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-base));width:440px;max-width:calc(100vw - 24px);max-height:74vh;box-shadow:var(--dsw-shadow-lv2);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--usg-blue:#1f6feb;--usg-cellEmpty:rgba(128,128,128,0.16);border-radius:12px;flex-direction:column;display:flex;position:fixed;bottom:128px;left:12px;overflow:hidden}",
38
+ ".usg_header{box-sizing:border-box;border-bottom:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-base));flex:none;justify-content:space-between;align-items:center;min-height:44px;padding:10px 12px;display:flex}",
39
+ ".usg_headerLeft{align-items:center;gap:8px;display:flex}",
40
+ ".usg_title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}",
41
+ ".usg_headerActions{align-items:center;gap:2px;display:flex}",
42
+ ".usg_iconButton{cursor:pointer;width:26px;height:26px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}",
43
+ ".usg_iconButton:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
44
+ ".usg_body{flex:1;min-height:0;padding:4px 14px 14px;overflow-y:auto}",
45
+ ".usg_section{margin-top:12px}",
46
+ ".usg_sectionTitle{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin:0 0 6px}",
47
+ ".usg_note{color:var(--dsw-alias-label-tertiary);margin:4px 0;font-size:12px;line-height:18px}",
48
+ ".usg_error{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:8px;margin:4px 0;padding:7px 8px;font-size:12px;line-height:18px;display:flex}",
49
+ ".usg_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0}",
50
+ ".usg_balanceCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-l1, transparent);border-radius:12px;padding:10px 12px;display:flex;flex-direction:column;gap:6px}",
51
+ ".usg_balanceMain{align-items:baseline;gap:8px;display:flex}",
52
+ ".usg_balanceAmount{color:var(--dsw-alias-label-primary);font-size:24px;font-weight:600;line-height:32px;font-variant-numeric:tabular-nums}",
53
+ ".usg_balanceStatus{align-items:center;gap:5px;font-size:12px;line-height:18px;display:inline-flex}",
54
+ ".usg_balanceOk{color:var(--dsw-alias-state-success-primary)}",
55
+ ".usg_balanceBad{color:var(--dsw-alias-state-error-primary)}",
56
+ ".usg_balanceRows{color:var(--dsw-alias-label-secondary);flex-direction:column;gap:2px;font-size:12px;line-height:18px;display:flex}",
57
+ ".usg_balanceRow{justify-content:space-between;display:flex}",
58
+ ".usg_providerPicker{align-items:center;gap:8px;margin:6px 0 8px;font-size:12px;line-height:18px;display:flex}",
59
+ ".usg_providerPickerLabel{color:var(--dsw-alias-label-tertiary);flex:none}",
60
+ ".usg_providerSelect{box-sizing:border-box;min-width:0;flex:1;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-base));border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 6px;font:inherit;font-size:12px;line-height:18px}",
61
+ ".usg_accountGrid{flex-direction:column;gap:8px;display:flex}",
62
+ ".usg_accountCard{--usg-providerAccent:#1f6feb;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:linear-gradient(135deg,color-mix(in srgb,var(--usg-providerAccent) 8%,transparent),transparent 42%);border-radius:12px;padding:10px 11px;display:flex;flex-direction:column;gap:9px}",
63
+ ".usg_accountCard[data-provider=deepseek],.usg_accountCard[data-provider=deepseek-official]{--usg-providerAccent:#1f6feb}",
64
+ ".usg_accountCard[data-provider=opencode-go]{--usg-providerAccent:#00a67d}",
65
+ ".usg_accountCard[data-provider=zai],.usg_accountCard[data-provider=zai-coding-cn]{--usg-providerAccent:#7656e8}",
66
+ ".usg_accountCard[data-provider=openrouter]{--usg-providerAccent:#6366f1}",
67
+ ".usg_accountCard[data-provider=moonshotai],.usg_accountCard[data-provider=moonshotai-cn],.usg_accountCard[data-provider=kimi],.usg_accountCard[data-provider=kimi-coding]{--usg-providerAccent:#e07a1f}",
68
+ ".usg_accountHead{align-items:center;gap:8px;display:flex}",
69
+ ".usg_accountMark{width:24px;height:24px;color:#fff;background:var(--usg-providerAccent);border-radius:7px;justify-content:center;align-items:center;font-size:10px;font-weight:700;display:flex;box-shadow:0 4px 12px color-mix(in srgb,var(--usg-providerAccent) 25%,transparent)}",
70
+ ".usg_accountIdentity{min-width:0;flex:1;display:flex;flex-direction:column}",
71
+ ".usg_accountName{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px}",
72
+ ".usg_accountPlan{color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;font-size:10px;line-height:14px;overflow:hidden}",
73
+ ".usg_accountStatus{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-fill-l2);border-radius:999px;padding:2px 7px;font-size:10px;line-height:16px;white-space:nowrap}",
74
+ ".usg_accountStatus[data-status=ok]{color:var(--usg-providerAccent);background:color-mix(in srgb,var(--usg-providerAccent) 12%,transparent)}",
75
+ ".usg_quotaList{flex-direction:column;gap:8px;display:flex}",
76
+ ".usg_quotaRow{display:flex;flex-direction:column;gap:4px}",
77
+ ".usg_quotaMeta{align-items:baseline;gap:8px;display:flex}",
78
+ ".usg_quotaLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}",
79
+ ".usg_quotaValue{color:var(--dsw-alias-label-primary);margin-left:auto;font-size:12px;font-weight:600;line-height:16px;font-variant-numeric:tabular-nums}",
80
+ ".usg_quotaReset{color:var(--dsw-alias-label-caption);font-size:9px;line-height:14px;white-space:nowrap}",
81
+ ".usg_quotaTrack{height:6px;background:var(--dsw-alias-fill-l2);border-radius:999px;overflow:hidden}",
82
+ ".usg_quotaFill{height:100%;background:var(--usg-providerAccent);border-radius:inherit;min-width:2px;transition:width .2s ease}",
83
+ ".usg_quotaEmpty{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:17px}",
84
+ ".usg_statsRow{display:flex;gap:8px}",
85
+ ".usg_stat{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;flex:1;flex-direction:column;gap:1px;padding:8px 10px;display:flex}",
86
+ ".usg_statValue{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:22px;font-variant-numeric:tabular-nums;white-space:nowrap}",
87
+ ".usg_statLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
88
+ ".usg_hitCaption{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}",
89
+ ".usg_hitCaption b{color:var(--dsw-alias-label-secondary);font-weight:600}",
90
+ ".usg_heat{overflow-x:auto}",
91
+ ".usg_heatHeader{justify-content:space-between;align-items:center;margin-bottom:6px;display:flex}",
92
+ ".usg_heatHeader .usg_sectionTitle{flex:none;margin:0}",
93
+ ".usg_monthNav{align-items:center;gap:2px;display:flex}",
94
+ ".usg_navButton{cursor:pointer;width:24px;height:24px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}",
95
+ ".usg_navButton:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
96
+ ".usg_navButton:disabled{color:var(--dsw-alias-label-caption);cursor:default}",
97
+ ".usg_monthTitle{color:var(--dsw-alias-label-primary);min-width:88px;font-size:12px;font-weight:500;line-height:24px;text-align:center;font-variant-numeric:tabular-nums}",
98
+ ".usg_todayButton{cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:6px;padding:0 6px;font-size:11px;line-height:24px}",
99
+ ".usg_todayButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
100
+ ".usg_monthGrid{flex-direction:column;gap:4px;width:100%;display:flex}",
101
+ ".usg_weekHeader{color:var(--dsw-alias-label-tertiary);grid-template-columns:repeat(7,1fr);gap:4px;display:grid}",
102
+ ".usg_weekLabel{font-size:10px;line-height:16px;text-align:center}",
103
+ ".usg_heatRow{grid-template-columns:repeat(7,1fr);gap:4px;display:grid}",
104
+ ".usg_cell{aspect-ratio:1/1;min-width:0;width:100%;border-radius:8px;background:var(--usg-cellEmpty);border:0;padding:0;cursor:pointer;justify-content:center;align-items:center;font-family:inherit;display:flex}",
105
+ ".usg_cell:hover{box-shadow:0 0 0 1px var(--dsw-alias-label-secondary)}",
106
+ ".usg_cellToday{box-shadow:0 0 0 1px var(--usg-blue)}",
107
+ ".usg_cellToday:hover{box-shadow:0 0 0 1px var(--usg-blue)}",
108
+ ".usg_cellSelected{box-shadow:0 0 0 2px var(--dsw-alias-label-primary)}",
109
+ ".usg_cellSelected:hover{box-shadow:0 0 0 2px var(--dsw-alias-label-primary)}",
110
+ ".usg_cellDay{font-size:12px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;pointer-events:none}",
111
+ ".usg_emptyCell{aspect-ratio:1/1;min-width:0;width:100%}",
112
+ ".usg_legend{align-items:center;gap:4px;margin-top:6px;font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary);display:flex}",
113
+ ".usg_legendSwatch{width:10px;height:10px;border-radius:2px;background:var(--dsw-alias-fill-l2)}",
114
+ ".usg_days{flex-direction:column;display:flex}",
115
+ ".usg_day{width:100%;min-height:30px;align-items:center;gap:8px;border:0;background:0 0;border-bottom:1px solid var(--dsw-alias-border-l1);padding:5px 0;font:inherit;text-align:left;cursor:pointer;display:flex}",
116
+ ".usg_day:last-child{border-bottom:0}",
117
+ ".usg_day:hover{background:var(--dsw-alias-interactive-bg-hover)}",
118
+ ".usg_dayDate{color:var(--dsw-alias-label-secondary);flex:none;width:104px;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}",
119
+ ".usg_dayTokens{color:var(--dsw-alias-label-primary);flex:none;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}",
120
+ ".usg_dayHit{color:var(--dsw-alias-label-tertiary);flex:none;width:52px;font-size:11px;line-height:18px;font-variant-numeric:tabular-nums;text-align:right}",
121
+ ".usg_dayBar{background:var(--usg-blue);border-radius:2px;height:6px;flex:1;min-width:4px;opacity:.65}",
122
+ ".usg_detailHeader{align-items:center;gap:8px;display:flex}",
123
+ ".usg_back{cursor:pointer;width:26px;height:26px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none}",
124
+ ".usg_back:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
125
+ ".usg_detailDate{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}",
126
+ ".usg_detailHit{color:var(--dsw-alias-label-tertiary);margin-left:auto;font-size:11px;line-height:20px;font-variant-numeric:tabular-nums}",
127
+ ".usg_detailSummary{color:var(--dsw-alias-label-secondary);margin:6px 0 8px;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}",
128
+ ".usg_modelRow{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin-bottom:8px;padding:8px 10px;display:flex;flex-direction:column;gap:4px}",
129
+ ".usg_modelRow:last-child{margin-bottom:0}",
130
+ ".usg_modelHead{align-items:center;gap:8px;display:flex}",
131
+ ".usg_modelName{color:var(--dsw-alias-label-primary);min-width:0;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;font-weight:500;line-height:18px;overflow:hidden}",
132
+ ".usg_modelTokens{color:var(--dsw-alias-label-primary);flex:none;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}",
133
+ ".usg_modelHit{color:var(--dsw-alias-label-tertiary);flex:none;width:56px;font-size:11px;line-height:18px;font-variant-numeric:tabular-nums;text-align:right}",
134
+ ".usg_modelBarTrack{background:var(--dsw-alias-fill-l2);border-radius:2px;height:5px;overflow:hidden}",
135
+ ".usg_modelBar{background:var(--usg-blue);border-radius:2px;height:5px}",
136
+ ".usg_modelMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}",
137
+ ".usg_footerNote{color:var(--dsw-alias-label-caption);margin-top:10px;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}"
138
+ ].join("");
139
+ const tagId = "dsh-usage-stats/UsageStats.module.css";
140
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
141
+ const tag = document.createElement("style");
142
+ tag.dataset.plugin = "dsh-usage-stats";
143
+ tag.dataset.pluginCss = tagId;
144
+ tag.textContent = css;
145
+ document.head.appendChild(tag);
146
+ }
147
+ const S = {
148
+ layer: "usg_layer",
149
+ rail: "usg_rail",
150
+ footerButtons: "usg_footerButtons",
151
+ badge: "usg_badge",
152
+ badgeLabel: "usg_badgeLabel",
153
+ badgeAmount: "usg_badgeAmount",
154
+ badgeOk: "usg_badgeOk",
155
+ badgeBad: "usg_badgeBad",
156
+ badgeCount: "usg_badgeCount",
157
+ panel: "usg_panel",
158
+ header: "usg_header",
159
+ headerLeft: "usg_headerLeft",
160
+ title: "usg_title",
161
+ headerActions: "usg_headerActions",
162
+ iconButton: "usg_iconButton",
163
+ body: "usg_body",
164
+ section: "usg_section",
165
+ sectionTitle: "usg_sectionTitle",
166
+ note: "usg_note",
167
+ error: "usg_error",
168
+ retry: "usg_retry",
169
+ providerPicker: "usg_providerPicker",
170
+ providerPickerLabel: "usg_providerPickerLabel",
171
+ providerSelect: "usg_providerSelect",
172
+ accountGrid: "usg_accountGrid",
173
+ accountCard: "usg_accountCard",
174
+ accountHead: "usg_accountHead",
175
+ accountMark: "usg_accountMark",
176
+ accountIdentity: "usg_accountIdentity",
177
+ accountName: "usg_accountName",
178
+ accountPlan: "usg_accountPlan",
179
+ accountStatus: "usg_accountStatus",
180
+ quotaList: "usg_quotaList",
181
+ quotaRow: "usg_quotaRow",
182
+ quotaMeta: "usg_quotaMeta",
183
+ quotaLabel: "usg_quotaLabel",
184
+ quotaValue: "usg_quotaValue",
185
+ quotaReset: "usg_quotaReset",
186
+ quotaTrack: "usg_quotaTrack",
187
+ quotaFill: "usg_quotaFill",
188
+ quotaEmpty: "usg_quotaEmpty",
189
+ balanceCard: "usg_balanceCard",
190
+ balanceMain: "usg_balanceMain",
191
+ balanceAmount: "usg_balanceAmount",
192
+ balanceStatus: "usg_balanceStatus",
193
+ balanceOk: "usg_balanceOk",
194
+ balanceBad: "usg_balanceBad",
195
+ balanceRows: "usg_balanceRows",
196
+ balanceRow: "usg_balanceRow",
197
+ statsRow: "usg_statsRow",
198
+ stat: "usg_stat",
199
+ statValue: "usg_statValue",
200
+ statLabel: "usg_statLabel",
201
+ hitCaption: "usg_hitCaption",
202
+ heat: "usg_heat",
203
+ heatHeader: "usg_heatHeader",
204
+ monthNav: "usg_monthNav",
205
+ navButton: "usg_navButton",
206
+ monthTitle: "usg_monthTitle",
207
+ todayButton: "usg_todayButton",
208
+ monthGrid: "usg_monthGrid",
209
+ weekHeader: "usg_weekHeader",
210
+ weekLabel: "usg_weekLabel",
211
+ heatRow: "usg_heatRow",
212
+ cell: "usg_cell",
213
+ cellSelected: "usg_cellSelected",
214
+ cellToday: "usg_cellToday",
215
+ cellDay: "usg_cellDay",
216
+ emptyCell: "usg_emptyCell",
217
+ legend: "usg_legend",
218
+ legendSwatch: "usg_legendSwatch",
219
+ days: "usg_days",
220
+ day: "usg_day",
221
+ dayDate: "usg_dayDate",
222
+ dayTokens: "usg_dayTokens",
223
+ dayHit: "usg_dayHit",
224
+ dayBar: "usg_dayBar",
225
+ detailHeader: "usg_detailHeader",
226
+ back: "usg_back",
227
+ detailDate: "usg_detailDate",
228
+ detailHit: "usg_detailHit",
229
+ detailSummary: "usg_detailSummary",
230
+ modelRow: "usg_modelRow",
231
+ modelHead: "usg_modelHead",
232
+ modelName: "usg_modelName",
233
+ modelTokens: "usg_modelTokens",
234
+ modelHit: "usg_modelHit",
235
+ modelBarTrack: "usg_modelBarTrack",
236
+ modelBar: "usg_modelBar",
237
+ modelMeta: "usg_modelMeta",
238
+ footerNote: "usg_footerNote"
239
+ };
240
+ //#endregion
241
+
242
+ //#region helpers
243
+ /** Local `YYYY-MM-DD` for a Date. */
244
+ function dayKeyOf(date) {
245
+ const month = String(date.getMonth() + 1).padStart(2, "0");
246
+ const day = String(date.getDate()).padStart(2, "0");
247
+ return `${date.getFullYear()}-${month}-${day}`;
248
+ }
249
+
250
+ /** Today's local `YYYY-MM-DD`. */
251
+ function todayKey() {
252
+ return dayKeyOf(new Date());
253
+ }
254
+
255
+ /** Current month key `YYYY-MM`. */
256
+ function currentMonthKey() {
257
+ const now = new Date();
258
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
259
+ }
260
+
261
+ /** Shift a `YYYY-MM` key by a signed month delta. */
262
+ function shiftMonth(key, delta) {
263
+ const [year, month] = key.split("-").map(Number);
264
+ const date = new Date(year, month - 1 + delta, 1);
265
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
266
+ }
267
+
268
+ /** Localized `YYYY-MM` → e.g. "2026年8月" / "Aug 2026". */
269
+ function monthLabelOf(key, translate) {
270
+ const [year, month] = key.split("-").map(Number);
271
+ return translate("month.year", { year, month: monthName(month - 1, translate) });
272
+ }
273
+
274
+ /** Group thousands. */
275
+ function fmt(n) {
276
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
277
+ }
278
+
279
+ /** Compact form: 1234 → "1.2k". */
280
+ function fmtCompact(n) {
281
+ if (n < 1000) return String(n);
282
+ if (n < 1000000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
283
+ return `${(n / 1000000).toFixed(1)}m`;
284
+ }
285
+
286
+ /** Hit-rate display: null/undefined → "—". */
287
+ function fmtHit(hitRate) {
288
+ return hitRate === null || hitRate === void 0 ? "—" : `${hitRate}%`;
289
+ }
290
+
291
+ /** Currency-aware amount: `¥ 36.44` / `$ 12.00` (Intl, fallback keeps the raw value). */
292
+ function fmtCurrency(amount, currency) {
293
+ if (amount === void 0 || amount === null) return "—";
294
+ const numeric = Number(amount);
295
+ if (!Number.isFinite(numeric)) return "—";
296
+ try {
297
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: currency ?? "CNY" }).format(numeric);
298
+ } catch {
299
+ return `${currency ?? "CNY"} ${amount}`;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Collapsed-badge account value, derived from the unified v0.2.0 account
305
+ * snapshot. Balance providers show a real-currency amount; subscription /
306
+ * token-plan providers show the LOWEST remaining percent across all quota
307
+ * windows. Returns null for loading / not-configured / unsupported /
308
+ * unavailable / stale states so the badge omits the numeric value rather
309
+ * than rendering a false warning — a stale snapshot that still carries
310
+ * previous balance or windows data must NOT render a colored value.
311
+ * @returns `{ kind: "balance", value, display }`, `{ kind: "percent", value, display }`, or null.
312
+ */
313
+ function badgeAccountValue(account) {
314
+ if (account === null || account === void 0) return null;
315
+ if (account.status !== "ok" || account.stale === true) return null;
316
+ if (account.mode === "subscription") {
317
+ const percents = Array.isArray(account.windows)
318
+ ? account.windows
319
+ .map((w) => w && typeof w.remainingPercent === "number" ? w.remainingPercent : null)
320
+ .filter((v) => v !== null)
321
+ : [];
322
+ if (percents.length === 0) return null;
323
+ const value = Math.min(...percents);
324
+ return { kind: "percent", value, display: `${value}%` };
325
+ }
326
+ if (account.balance !== null && account.balance !== void 0 && typeof account.balance.remaining === "number") {
327
+ return { kind: "balance", value: account.balance.remaining, display: fmtCurrency(account.balance.remaining, account.balance.currency) };
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Low-balance warning policy for the collapsed badge, isolated by
334
+ * `account.mode`: balance uses an absolute amount (remaining <= 5 currency
335
+ * units), subscription uses the lowest remaining percent (<= 5%). A null
336
+ * account value is never a warning.
337
+ */
338
+ function badgeWarnOf(account) {
339
+ const value = badgeAccountValue(account);
340
+ if (value === null) return false;
341
+ return value.value <= 5;
342
+ }
343
+
344
+ /**
345
+ * Per-request staleness guard: each `start()` bumps a private counter and
346
+ * only the most recent start may `isCurrent()`. Usage and balance each
347
+ * hold their OWN loader, so the two never invalidate each other (the
348
+ * shared-counter race that dropped the first usage response).
349
+ */
350
+ function createLoader() {
351
+ let current = 0;
352
+ return {
353
+ start: () => ++current,
354
+ isCurrent: (id) => id === current
355
+ };
356
+ }
357
+
358
+
359
+ /** True when a pointer event happened outside both the portaled panel and its sidebar badge. */
360
+ function shouldDismissPanel(path, target, layer, panel) {
361
+ const eventPath = Array.isArray(path) ? path : [];
362
+ const inside = (root) => root !== null && root !== void 0 && (
363
+ eventPath.includes(root)
364
+ || (target !== null && target !== void 0 && typeof root.contains === "function" && root.contains(target))
365
+ );
366
+ return !inside(layer) && !inside(panel);
367
+ }
368
+
369
+ /** Defense-in-depth filter for server-provided, secret-free diagnostic reasons. */
370
+ function safeDiagnosticReason(reason) {
371
+ if (typeof reason !== "string") return null;
372
+ const value = reason.trim();
373
+ if (value === "" || value.length > 160) return null;
374
+ if (/(authorization|bearer\s|api[_-]?key|cookie|token\s*[=:]|sk-[a-z0-9])/i.test(value)) return null;
375
+ return value;
376
+ }
377
+
378
+ function diagnosticReasonText(reason, translate) {
379
+ const value = safeDiagnosticReason(reason);
380
+ if (value === null) return null;
381
+ if (value === "dns-resolution-failed") return translate("account.reason.dnsResolutionFailed");
382
+ if (value === "all-addresses-unreachable") return translate("account.reason.allAddressesUnreachable");
383
+ if (value === "upstream-not-json") return translate("account.reason.upstreamNotJson");
384
+ if (value === "upstream-invalid-json") return translate("account.reason.upstreamInvalidJson");
385
+ if (value === "sub2api-balance-shape-unrecognized") return translate("account.reason.sub2apiBalanceShapeUnrecognized");
386
+ return value;
387
+ }
388
+
389
+ /**
390
+ * Normalize server-provided account metadata for the single selector.
391
+ * Adapter/mode selection belongs to the server registry, never UI guesses.
392
+ */
393
+ function buildProviderChoices(providers) {
394
+ return Array.isArray(providers) ? providers.map((provider) => ({
395
+ ...provider,
396
+ accountMode: provider.accountMode ?? "balance"
397
+ })) : [];
398
+ }
399
+
400
+ /** Locale-safe template interpolation: `t("key", {a})` replaces `{a}`. */
401
+ function interpolate(template, params) {
402
+ if (params === void 0) return template;
403
+ return template.replace(/\{(\w+)\}/g, (match, key) => (Object.hasOwn(params, key) ? String(params[key]) : match));
404
+ }
405
+
406
+ async function fetchJson(path) {
407
+ const response = await fetch(path, { headers: { accept: "application/json" } });
408
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
409
+ const payload = await response.json();
410
+ if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
411
+ return payload;
412
+ }
413
+
414
+ /**
415
+ * Build one month's calendar heatmap: weeks as rows (Mon-first), only
416
+ * the month's own days, padded with null placeholders. Cell tokens come
417
+ * from the day map; `max` is the month's largest daily total, used for
418
+ * the absolute log-scale color mapping.
419
+ * @param dayMap - date key → day entry map.
420
+ * @param year - calendar year.
421
+ * @param month - zero-based month.
422
+ * @returns `{ weeks, max }`.
423
+ */
424
+ function buildMonthHeatmap(dayMap, year, month) {
425
+ const first = new Date(year, month, 1);
426
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
427
+ const lead = (first.getDay() + 6) % 7; // Monday = 0
428
+ const weeks = [];
429
+ let max = 0;
430
+ for (let w = 0; w * 7 < lead + daysInMonth; w += 1) {
431
+ const week = [];
432
+ for (let d = 0; d < 7; d += 1) {
433
+ const dayNum = w * 7 + d - lead + 1;
434
+ if (dayNum < 1 || dayNum > daysInMonth) {
435
+ week.push(null);
436
+ continue;
437
+ }
438
+ const date = new Date(year, month, dayNum);
439
+ const key = dayKeyOf(date);
440
+ const entry = dayMap.get(key);
441
+ const tokens = entry?.tokens ?? 0;
442
+ week.push({ key, day: dayNum, tokens, hitRate: entry?.cacheHitRate ?? null });
443
+ if (tokens > max) max = tokens;
444
+ }
445
+ weeks.push(week);
446
+ }
447
+ return { weeks, max };
448
+ }
449
+
450
+ /**
451
+ * Codex-style blue cell color: continuous square-root mapping against
452
+ * the month's max (more tokens → strictly deeper blue, no banding),
453
+ * rendered as a plain rgba overlay of #1f6feb so it works in every
454
+ * browser/theme without color-mix support. Zero is the neutral gray
455
+ * "empty" cell. Returns the cell's background and text color.
456
+ */
457
+ const BLUE_RGB = [31, 111, 235];
458
+ function cellColor(tokens, max) {
459
+ if (tokens <= 0) {
460
+ return {
461
+ background: "var(--usg-cellEmpty)",
462
+ color: "var(--dsw-alias-label-secondary)"
463
+ };
464
+ }
465
+ const ratio = max > 0 ? Math.sqrt(tokens / max) : 1;
466
+ const alpha = Math.min(1, 0.22 + 0.78 * ratio);
467
+ return {
468
+ background: `rgba(${BLUE_RGB[0]}, ${BLUE_RGB[1]}, ${BLUE_RGB[2]}, ${alpha.toFixed(3)})`,
469
+ color: alpha >= 0.6 ? "rgba(255,255,255,0.95)" : "var(--dsw-alias-label-primary)"
470
+ };
471
+ }
472
+ //#endregion
473
+
474
+ //#region UsageStatsPanel
475
+ /**
476
+ * Sidebar footer action: badge + floating panel with balance and usage.
477
+ * @param props - `wide` from the sidebar shell, `t` bound by the slot runtime.
478
+ */
479
+ function UsageStatsPanel({ wide, t }) {
480
+ const translate = (key, params) => interpolate(t !== void 0 ? t(key) : key, params);
481
+ const [open, setOpen] = react.useState(false);
482
+ const [usage, setUsage] = react.useState(null);
483
+ const [usageError, setUsageError] = react.useState(null);
484
+ const [selectedDay, setSelectedDay] = react.useState(null);
485
+ const [viewMonth, setViewMonth] = react.useState(() => currentMonthKey());
486
+ const [providers, setProviders] = react.useState([]);
487
+ const [providersLoaded, setProvidersLoaded] = react.useState(false);
488
+ const [selectedProvider, setSelectedProvider] = react.useState(null);
489
+ const [account, setAccount] = react.useState(null);
490
+ const [accountLoading, setAccountLoading] = react.useState(false);
491
+ const [accountError, setAccountError] = react.useState(null);
492
+ const [refreshedAt, setRefreshedAt] = react.useState(null);
493
+ const mountedRef = react.useRef(true);
494
+ const usageLoaderRef = react.useRef(null);
495
+ const accountLoaderRef = react.useRef(null);
496
+ const layerRef = react.useRef(null);
497
+ const panelRef = react.useRef(null);
498
+ if (usageLoaderRef.current === null) usageLoaderRef.current = createLoader();
499
+ if (accountLoaderRef.current === null) accountLoaderRef.current = createLoader();
500
+ const providerChoices = react.useMemo(() => buildProviderChoices(providers), [providers]);
501
+ const selectedProviderInfo = providerChoices.find((provider) => provider.id === selectedProvider) ?? null;
502
+
503
+ const loadUsage = react.useCallback(() => {
504
+ const seq = usageLoaderRef.current.start();
505
+ setUsageError(null);
506
+ fetchJson("/api/usage-stats/usage").then((payload) => {
507
+ if (!mountedRef.current || !usageLoaderRef.current.isCurrent(seq)) return;
508
+ if (payload.ok !== true) {
509
+ setUsageError(payload.message ?? "usage aggregation failed");
510
+ return;
511
+ }
512
+ setUsage(payload);
513
+ setRefreshedAt(Date.now());
514
+ }).catch((error) => {
515
+ if (!mountedRef.current || !usageLoaderRef.current.isCurrent(seq)) return;
516
+ setUsageError(error instanceof Error ? error.message : String(error));
517
+ });
518
+ }, []);
519
+
520
+ const loadProviders = react.useCallback(() => {
521
+ fetchJson("/api/usage-stats/providers").then((payload) => {
522
+ if (!mountedRef.current) return;
523
+ if (payload.ok !== true) {
524
+ setProvidersLoaded(true);
525
+ return;
526
+ }
527
+ const list = Array.isArray(payload.providers) ? payload.providers : [];
528
+ setProviders(list);
529
+ setProvidersLoaded(true);
530
+ }).catch(() => { setProvidersLoaded(true); });
531
+ }, []);
532
+
533
+ const loadAccount = react.useCallback((providerId, force = false) => {
534
+ const seq = accountLoaderRef.current.start();
535
+ setAccountLoading(true);
536
+ setAccountError(null);
537
+ const target = providerId;
538
+ if (target === null) {
539
+ setAccountLoading(false);
540
+ setAccountError("no providers");
541
+ return;
542
+ }
543
+ const query = `?provider=${encodeURIComponent(target)}${force ? "&refresh=1" : ""}`;
544
+ fetchJson(`/api/usage-stats/account${query}`).then((payload) => {
545
+ if (!mountedRef.current || !accountLoaderRef.current.isCurrent(seq)) return;
546
+ if (payload.ok !== true) {
547
+ setAccountError(payload.message ?? "account fetch failed");
548
+ return;
549
+ }
550
+ setAccount(payload.account);
551
+ setRefreshedAt(payload.account?.fetchedAt ?? Date.now());
552
+ }).catch((error) => {
553
+ if (!mountedRef.current || !accountLoaderRef.current.isCurrent(seq)) return;
554
+ setAccountError(error instanceof Error ? error.message : String(error));
555
+ }).finally(() => {
556
+ if (mountedRef.current && accountLoaderRef.current.isCurrent(seq)) setAccountLoading(false);
557
+ });
558
+ }, []);
559
+
560
+ react.useEffect(() => {
561
+ mountedRef.current = true;
562
+ return () => {
563
+ mountedRef.current = false;
564
+ };
565
+ }, []);
566
+
567
+ // The host footer-actions container lays sidebar.footer.action list
568
+ // registrations out in a ROW, so two full-width entries squeeze onto
569
+ // one line and the later one (us) is pushed out of view (#21). Switch
570
+ // the container to column stacking while mounted and restore its
571
+ // previous inline value on unmount. Walking up (instead of targeting
572
+ // the host's hashed class) keeps this working across dsh versions,
573
+ // and a container that is already columnar is left untouched.
574
+ react.useEffect(() => {
575
+ let host = layerRef.current?.parentElement ?? null;
576
+ for (let depth = 0; host !== null && depth < 3; depth += 1) {
577
+ if (window.getComputedStyle(host).display.includes("flex")) break;
578
+ host = host.parentElement;
579
+ }
580
+ if (host === null) return void 0;
581
+ const hostStyle = window.getComputedStyle(host);
582
+ if (!hostStyle.display.includes("flex") || hostStyle.flexDirection === "column") return void 0;
583
+ const previous = host.style.flexDirection;
584
+ host.style.flexDirection = "column";
585
+ return () => {
586
+ host.style.flexDirection = previous;
587
+ };
588
+ }, []);
589
+
590
+
591
+ // Utility-popover dismissal: the panel is portaled to document.body, so
592
+ // use capture-phase pointerdown + composedPath rather than DOM ancestry.
593
+ react.useEffect(() => {
594
+ if (!open) return void 0;
595
+ const onPointerDown = (event) => {
596
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
597
+ if (shouldDismissPanel(path, event.target, layerRef.current, panelRef.current)) setOpen(false);
598
+ };
599
+ const onKeyDown = (event) => {
600
+ if (event.key === "Escape") setOpen(false);
601
+ };
602
+ document.addEventListener("pointerdown", onPointerDown, true);
603
+ document.addEventListener("keydown", onKeyDown, true);
604
+ return () => {
605
+ document.removeEventListener("pointerdown", onPointerDown, true);
606
+ document.removeEventListener("keydown", onKeyDown, true);
607
+ };
608
+ }, [open]);
609
+
610
+ // Keep exactly one valid provider selected across independent provider
611
+ // and subscription responses. DeepSeek remains the initial preference.
612
+ react.useEffect(() => {
613
+ if (!providersLoaded || providerChoices.length === 0) return;
614
+ setSelectedProvider((current) => {
615
+ if (current !== null && providerChoices.some((provider) => provider.id === current)) return current;
616
+ return providerChoices.find((provider) => provider.id === "deepseek-official" && provider.configured)?.id
617
+ ?? providerChoices.find((provider) => provider.id === "deepseek")?.id
618
+ ?? providerChoices.find((provider) => provider.configured)?.id
619
+ ?? providerChoices[0].id;
620
+ });
621
+ }, [providerChoices, providersLoaded]);
622
+
623
+ react.useEffect(() => {
624
+ if (!open) return;
625
+ loadUsage();
626
+ loadProviders();
627
+ const usageTimer = window.setInterval(loadUsage, 60000);
628
+ const providerTimer = window.setInterval(loadProviders, 300000);
629
+ return () => {
630
+ window.clearInterval(usageTimer);
631
+ window.clearInterval(providerTimer);
632
+ };
633
+ }, [open, loadUsage, loadProviders]);
634
+
635
+ // Fetch the selected account for BOTH the panel and the collapsed
636
+ // sidebar badge. The server refreshes all providers in the background
637
+ // (five-minute cache); the client re-reads that cache every five
638
+ // minutes (no refresh=1 — the upstream fetch stays server-owned) so
639
+ // the badge/panel never stays on a stale balance or quota value.
640
+ react.useEffect(() => {
641
+ if (selectedProvider === null) return;
642
+ loadAccount(selectedProvider);
643
+ const cacheTimer = window.setInterval(() => loadAccount(selectedProvider), 300000);
644
+ return () => {
645
+ window.clearInterval(cacheTimer);
646
+ };
647
+ }, [selectedProvider, loadAccount]);
648
+
649
+ const dayMap = react.useMemo(() => {
650
+ const map = new Map();
651
+ if (usage !== null && Array.isArray(usage.days)) {
652
+ for (const day of usage.days) map.set(day.date, day);
653
+ }
654
+ return map;
655
+ }, [usage]);
656
+
657
+ // Drop a stale selection when refreshed data no longer has that day.
658
+ react.useEffect(() => {
659
+ if (selectedDay !== null && !dayMap.has(selectedDay)) setSelectedDay(null);
660
+ }, [dayMap, selectedDay]);
661
+
662
+ // Never browse past the current month.
663
+ react.useEffect(() => {
664
+ const current = currentMonthKey();
665
+ if (viewMonth > current) setViewMonth(current);
666
+ }, [viewMonth]);
667
+
668
+ const heat = react.useMemo(() => {
669
+ // viewMonth is `YYYY-MM` with a 1-based month; the builder wants 0-based.
670
+ const [year, monthOneBased] = viewMonth.split("-").map(Number);
671
+ return buildMonthHeatmap(dayMap, year, monthOneBased - 1);
672
+ }, [dayMap, viewMonth]);
673
+
674
+ const stats = react.useMemo(() => {
675
+ if (usage === null || !Array.isArray(usage.days)) return null;
676
+ const today = todayKey();
677
+ const month = today.slice(0, 7);
678
+ let todayEntry = null;
679
+ let dayTokens = 0;
680
+ let monthTokens = 0;
681
+ let total = usage.total?.tokens ?? 0;
682
+ for (const day of usage.days) {
683
+ if (day.date === today) {
684
+ dayTokens = day.tokens ?? 0;
685
+ todayEntry = day;
686
+ }
687
+ if (day.date.startsWith(month)) monthTokens += day.tokens ?? 0;
688
+ }
689
+ return { dayTokens, monthTokens, total, todayHit: todayEntry?.cacheHitRate ?? null };
690
+ }, [usage]);
691
+
692
+ const recent = react.useMemo(() => {
693
+ // Last 14 CALENDAR days (not "last 14 recorded days"): days without
694
+ // usage inside the window are omitted from the list.
695
+ if (usage === null || !Array.isArray(usage.days)) return [];
696
+ const cutoff = new Date();
697
+ cutoff.setDate(cutoff.getDate() - 13);
698
+ const cutoffKey = dayKeyOf(cutoff);
699
+ return usage.days.filter((day) => day.date >= cutoffKey && day.date <= todayKey()).reverse();
700
+ }, [usage]);
701
+
702
+ const selectedEntry = selectedDay !== null ? dayMap.get(selectedDay) ?? null : null;
703
+ const badgeCount = stats !== null ? fmtCompact(stats.dayTokens) : null;
704
+
705
+ // Collapsed-badge account value, derived from the unified v0.2.0
706
+ // account snapshot. Balance providers show a real-currency amount;
707
+ // subscription/token-plan providers show the LOWEST remaining percent
708
+ // across all quota windows. Policy stays isolated by account.mode:
709
+ // balance: remaining <= 5 (currency units) => warning
710
+ // subscription: min(remainingPercent) <= 5 => warning
711
+ // Loading / not-configured / unsupported / unavailable states omit the
712
+ // numeric value rather than rendering a false warning.
713
+ const badgeAmount = badgeAccountValue(account);
714
+ const badgeWarn = badgeWarnOf(account);
715
+ // Badge layout: 「用量/余额」label, the account value in the middle
716
+ // (tinted by the warning policy), today's token count on the right.
717
+ // When the account value is unavailable, only label + token remain.
718
+ const badgeLabel = translate("panel.badge");
719
+ const badgeAmountText = badgeAmount !== null ? badgeAmount.display : null;
720
+ const badgeToneClass = badgeAmount === null ? null : (badgeWarn ? S.badgeBad : S.badgeOk);
721
+
722
+ const retry = () => {
723
+ loadUsage();
724
+ loadProviders();
725
+ if (selectedProvider !== null) loadAccount(selectedProvider, true);
726
+ };
727
+
728
+ const updatedLabel = refreshedAt === null ? "" : translate("panel.updatedAt", {
729
+ time: new Date(refreshedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
730
+ });
731
+
732
+ return react_jsx_runtime.jsxs("div", {
733
+ ref: layerRef,
734
+ className: wide ? S.layer : `${S.layer} ${S.rail}`,
735
+ children: [
736
+ // Portal the panel to document.body: sidebar-scoped theme tokens
737
+ // (e.g. glass skins that set light label colors for a dark sidebar)
738
+ // would otherwise leak into the panel and clash with its
739
+ // overlay surface from the global scope (#17).
740
+ open && react_dom.createPortal(react_jsx_runtime.jsxs("section", {
741
+ ref: panelRef,
742
+ className: S.panel,
743
+ "data-usage-stats-panel": true,
744
+ "aria-label": translate("panel.title"),
745
+ children: [
746
+ react_jsx_runtime.jsxs("header", {
747
+ className: S.header,
748
+ children: [
749
+ react_jsx_runtime.jsxs("div", {
750
+ className: S.headerLeft,
751
+ children: [
752
+ react_jsx_runtime.jsx(primitives.IconDataOutline16, { size: 16 }),
753
+ react_jsx_runtime.jsx("span", { className: S.title, children: translate("panel.title") })
754
+ ]
755
+ }),
756
+ react_jsx_runtime.jsxs("div", {
757
+ className: S.headerActions,
758
+ children: [
759
+ react_jsx_runtime.jsx(primitives.Tooltip, {
760
+ label: translate("action.refresh"),
761
+ side: "bottom",
762
+ delayMs: 500,
763
+ children: react_jsx_runtime.jsx("button", {
764
+ type: "button",
765
+ className: S.iconButton,
766
+ "aria-label": translate("action.refresh"),
767
+ onClick: retry,
768
+ children: react_jsx_runtime.jsx(primitives.IconRefreshOutline14, { size: 14 })
769
+ })
770
+ }),
771
+ react_jsx_runtime.jsx(primitives.Tooltip, {
772
+ label: translate("action.close"),
773
+ side: "bottom",
774
+ delayMs: 500,
775
+ children: react_jsx_runtime.jsx("button", {
776
+ type: "button",
777
+ className: S.iconButton,
778
+ "aria-label": translate("action.close"),
779
+ onClick: () => setOpen(false),
780
+ children: react_jsx_runtime.jsx(primitives.IconCloseOutline16, { size: 14 })
781
+ })
782
+ })
783
+ ]
784
+ })
785
+ ]
786
+ }),
787
+ react_jsx_runtime.jsxs("div", {
788
+ className: S.body,
789
+ children: [
790
+ selectedEntry !== null ? react_jsx_runtime.jsx(DayDetail, {
791
+ day: selectedEntry,
792
+ translate,
793
+ onBack: () => setSelectedDay(null)
794
+ }) : react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
795
+ children: [
796
+ react_jsx_runtime.jsx("section", {
797
+ className: S.section,
798
+ children: react_jsx_runtime.jsx("h3", { className: S.sectionTitle, children: translate("account.title") })
799
+ }),
800
+ react_jsx_runtime.jsx(ProviderPicker, {
801
+ providers: providerChoices,
802
+ selectedProvider,
803
+ onSelect: (id) => setSelectedProvider(id),
804
+ translate
805
+ }),
806
+ selectedProviderInfo === null ? react_jsx_runtime.jsx("p", { className: S.note, children: translate("account.loading") }) : react_jsx_runtime.jsx("div", {
807
+ className: S.accountGrid,
808
+ children: react_jsx_runtime.jsx(ProviderAccountCard, {
809
+ provider: selectedProviderInfo,
810
+ account: account?.id === selectedProvider ? account : null,
811
+ accountLoading,
812
+ accountError,
813
+ translate,
814
+ onRetry: () => loadAccount(selectedProvider, true)
815
+ }, selectedProviderInfo.id)
816
+ }),
817
+ react_jsx_runtime.jsx("section", {
818
+ className: S.section,
819
+ children: react_jsx_runtime.jsx("h3", { className: S.sectionTitle, children: translate("usage.title") })
820
+ }),
821
+ stats === null && usageError === null ? react_jsx_runtime.jsx("p", { className: S.note, children: translate("usage.loading") }) : null,
822
+ usageError !== null ? react_jsx_runtime.jsxs("div", {
823
+ className: S.error,
824
+ children: [
825
+ react_jsx_runtime.jsx("span", { children: translate("usage.error", { message: usageError }) }),
826
+ react_jsx_runtime.jsx("button", {
827
+ type: "button",
828
+ className: S.retry,
829
+ onClick: loadUsage,
830
+ children: translate("action.retry")
831
+ })
832
+ ]
833
+ }) : null,
834
+ stats !== null && react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
835
+ children: [
836
+ react_jsx_runtime.jsxs("div", {
837
+ className: S.statsRow,
838
+ children: [
839
+ react_jsx_runtime.jsx("div", { className: S.stat, children: [react_jsx_runtime.jsx("span", { className: S.statValue, children: fmt(stats.dayTokens) }), react_jsx_runtime.jsx("span", { className: S.statLabel, children: translate("usage.today") })] }),
840
+ react_jsx_runtime.jsx("div", { className: S.stat, children: [react_jsx_runtime.jsx("span", { className: S.statValue, children: fmt(stats.monthTokens) }), react_jsx_runtime.jsx("span", { className: S.statLabel, children: translate("usage.month") })] }),
841
+ react_jsx_runtime.jsx("div", { className: S.stat, children: [react_jsx_runtime.jsx("span", { className: S.statValue, children: fmt(stats.total) }), react_jsx_runtime.jsx("span", { className: S.statLabel, children: translate("usage.total") })] })
842
+ ]
843
+ }),
844
+ react_jsx_runtime.jsx("p", {
845
+ className: S.hitCaption,
846
+ children: react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
847
+ children: [
848
+ translate("usage.hit.today"),
849
+ ": ",
850
+ react_jsx_runtime.jsx("b", { children: fmtHit(stats.todayHit) })
851
+ ]
852
+ })
853
+ })
854
+ ]
855
+ }),
856
+ usage !== null && usageError === null && react_jsx_runtime.jsxs("section", {
857
+ className: S.section,
858
+ children: [
859
+ react_jsx_runtime.jsxs("div", {
860
+ className: S.heatHeader,
861
+ children: [
862
+ react_jsx_runtime.jsx("h3", { className: S.sectionTitle, children: translate("usage.heatmap") }),
863
+ react_jsx_runtime.jsxs("div", {
864
+ className: S.monthNav,
865
+ children: [
866
+ react_jsx_runtime.jsx("button", {
867
+ type: "button",
868
+ className: S.navButton,
869
+ "aria-label": translate("action.prevMonth"),
870
+ onClick: () => setViewMonth((month) => shiftMonth(month, -1)),
871
+ children: react_jsx_runtime.jsx(primitives.IconChevronLeftOutline14, { size: 12 })
872
+ }),
873
+ react_jsx_runtime.jsx("span", { className: S.monthTitle, children: monthLabelOf(viewMonth, translate) }),
874
+ react_jsx_runtime.jsx("button", {
875
+ type: "button",
876
+ className: S.navButton,
877
+ "aria-label": translate("action.nextMonth"),
878
+ disabled: viewMonth >= currentMonthKey(),
879
+ onClick: () => setViewMonth((month) => shiftMonth(month, 1)),
880
+ children: react_jsx_runtime.jsx(primitives.IconChevronRightOutline14, { size: 12 })
881
+ }),
882
+ viewMonth !== currentMonthKey() && react_jsx_runtime.jsx("button", {
883
+ type: "button",
884
+ className: S.todayButton,
885
+ onClick: () => setViewMonth(currentMonthKey()),
886
+ children: translate("action.today")
887
+ })
888
+ ]
889
+ })
890
+ ]
891
+ }),
892
+ react_jsx_runtime.jsx(MonthHeatmap, {
893
+ heat,
894
+ translate,
895
+ selectedKey: selectedDay,
896
+ onSelect: setSelectedDay
897
+ })
898
+ ]
899
+ }),
900
+ recent.length > 0 && react_jsx_runtime.jsxs("section", {
901
+ className: S.section,
902
+ children: [
903
+ react_jsx_runtime.jsx("h3", { className: S.sectionTitle, children: translate("usage.recent") }),
904
+ react_jsx_runtime.jsx("div", {
905
+ className: S.days,
906
+ children: recent.map((day) => {
907
+ const maxRecent = Math.max(...recent.map((d) => d.tokens ?? 0), 1);
908
+ return react_jsx_runtime.jsxs("button", {
909
+ type: "button",
910
+ className: S.day,
911
+ onClick: () => setSelectedDay(day.date),
912
+ children: [
913
+ react_jsx_runtime.jsx("span", { className: S.dayDate, children: dayLabel(day.date, translate) }),
914
+ react_jsx_runtime.jsx("span", { className: S.dayTokens, children: fmt(day.tokens ?? 0) }),
915
+ react_jsx_runtime.jsx("span", { className: S.dayHit, children: fmtHit(day.cacheHitRate) }),
916
+ react_jsx_runtime.jsx("div", {
917
+ className: S.dayBar,
918
+ style: { width: `${Math.max(4, Math.round(100 * (day.tokens ?? 0) / maxRecent))}%` }
919
+ })
920
+ ]
921
+ }, day.date);
922
+ })
923
+ })
924
+ ]
925
+ }),
926
+ updatedLabel !== "" && react_jsx_runtime.jsx("p", { className: S.footerNote, children: updatedLabel })
927
+ ]
928
+ })
929
+ ]
930
+ })
931
+ ]
932
+ }), document.body),
933
+ react_jsx_runtime.jsx("div", {
934
+ className: S.footerButtons,
935
+ children: react_jsx_runtime.jsxs("button", {
936
+ type: "button",
937
+ className: S.badge,
938
+ "data-usage-stats-badge": true,
939
+ "aria-label": badgeAmount !== null ? `${translate("panel.badge")} ${badgeAmount.display}` : translate("panel.badge"),
940
+ "aria-expanded": open,
941
+ onClick: () => setOpen((value) => !value),
942
+ children: [
943
+ react_jsx_runtime.jsx(primitives.IconDataOutline16, { size: wide ? 14 : 18 }),
944
+ wide && react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
945
+ children: [
946
+ react_jsx_runtime.jsx("span", { className: S.badgeLabel, children: badgeLabel }),
947
+ badgeAmountText !== null && react_jsx_runtime.jsx("span", { className: badgeToneClass !== null ? `${S.badgeAmount} ${badgeToneClass}` : S.badgeAmount, children: badgeAmountText }),
948
+ badgeCount !== null && react_jsx_runtime.jsx("span", { className: S.badgeCount, children: badgeCount })
949
+ ]
950
+ })
951
+ ]
952
+ })
953
+ })
954
+ ]
955
+ });
956
+ }
957
+
958
+ function providerMark(provider) {
959
+ const known = {
960
+ "deepseek-official": "DS",
961
+ deepseek: "DS",
962
+ "opencode-go": "GO",
963
+ openrouter: "OR",
964
+ moonshotai: "K",
965
+ "moonshotai-cn": "K",
966
+ kimi: "K",
967
+ "kimi-coding": "K",
968
+ zai: "Z",
969
+ "zai-coding-cn": "Z"
970
+ };
971
+ return known[provider.id] ?? String(provider.displayName ?? provider.id).replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase();
972
+ }
973
+
974
+ /** Balance-mode body rendered inside the shared provider account frame. */
975
+ function BalanceContent({ balance, state, message, translate, onRetry }) {
976
+ if (state === "loading" || balance === null && state === "ok") return react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: translate("balance.loading") });
977
+ if (state === "blocked") return react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: translate("account.blocked") });
978
+ if (state === "unsupported") return react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: translate("balance.unsupported") });
979
+ if (state === "no-credential") return react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: translate("balance.noCredential", { ref: message ?? "" }) });
980
+ if (state === "error") return react_jsx_runtime.jsxs("div", {
981
+ className: S.error,
982
+ children: [
983
+ react_jsx_runtime.jsx("span", { children: translate("balance.error", { message: message ?? "" }) }),
984
+ react_jsx_runtime.jsx("button", { type: "button", className: S.retry, onClick: onRetry, children: translate("action.retry") })
985
+ ]
986
+ });
987
+ return react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
988
+ children: [
989
+ react_jsx_runtime.jsxs("div", {
990
+ className: S.balanceMain,
991
+ children: [
992
+ react_jsx_runtime.jsx("span", { className: S.balanceAmount, children: balance.unlimited ? "∞" : fmtCurrency(balance.remaining, balance.currency) }),
993
+ react_jsx_runtime.jsx("span", { className: S.accountPlan, children: translate("balance.remaining") })
994
+ ]
995
+ }),
996
+ react_jsx_runtime.jsx("div", {
997
+ className: S.balanceRows,
998
+ children: [
999
+ { value: balance.used, label: translate("balance.used") },
1000
+ { value: balance.total, label: translate("balance.total") },
1001
+ { value: balance.breakdown?.toppedUp, label: translate("balance.toppedUp") },
1002
+ { value: balance.breakdown?.granted, label: translate("balance.granted") }
1003
+ ].filter((row) => row.value !== null && row.value !== void 0).map((row, index) => react_jsx_runtime.jsxs("div", {
1004
+ className: S.balanceRow,
1005
+ children: [
1006
+ react_jsx_runtime.jsx("span", { children: row.label }),
1007
+ react_jsx_runtime.jsx("span", { children: fmtCurrency(row.value, balance.currency) })
1008
+ ]
1009
+ }, `${row.label}-${index}`))
1010
+ })
1011
+ ]
1012
+ });
1013
+ }
1014
+
1015
+ /** Provider selector shared by monetary and subscription account modes. */
1016
+ function ProviderPicker({ providers, selectedProvider, onSelect, translate }) {
1017
+ if (providers.length === 0) return null;
1018
+ return react_jsx_runtime.jsxs("label", {
1019
+ className: S.providerPicker,
1020
+ children: [
1021
+ react_jsx_runtime.jsx("span", { className: S.providerPickerLabel, children: translate("account.provider") }),
1022
+ react_jsx_runtime.jsx("select", {
1023
+ className: S.providerSelect,
1024
+ value: selectedProvider ?? "",
1025
+ "aria-label": translate("account.provider"),
1026
+ onChange: (event) => onSelect(event.target.value),
1027
+ children: providers.map((provider) => react_jsx_runtime.jsx("option", {
1028
+ value: provider.id,
1029
+ children: provider.displayName
1030
+ }, provider.id))
1031
+ })
1032
+ ]
1033
+ });
1034
+ }
1035
+
1036
+ function subscriptionStatusLabel(status, translate) {
1037
+ if (status === "ok") return translate("subscription.status.ok");
1038
+ if (status === "not-configured") return translate("subscription.status.notConfigured");
1039
+ if (status === "unauthorized") return translate("subscription.status.unauthorized");
1040
+ if (status === "rate-limited") return translate("subscription.status.rateLimited");
1041
+ if (status === "invalid-response") return translate("account.status.invalidResponse");
1042
+ if (status === "blocked") return translate("account.status.blocked");
1043
+ if (status === "unsupported") return translate("account.status.unsupported");
1044
+ return translate("subscription.status.unavailable");
1045
+ }
1046
+
1047
+ function quotaLabel(kind, translate) {
1048
+ if (kind === "session") return translate("subscription.window.session");
1049
+ if (kind === "daily") return translate("subscription.window.daily");
1050
+ if (kind === "weekly") return translate("subscription.window.weekly");
1051
+ if (kind === "monthly") return translate("subscription.window.monthly");
1052
+ if (kind === "quota") return translate("subscription.window.quota");
1053
+ if (kind === "billing") return translate("subscription.window.mcp");
1054
+ return kind;
1055
+ }
1056
+
1057
+ function resetLabel(resetsAt, translate) {
1058
+ if (typeof resetsAt !== "string") return "";
1059
+ const date = new Date(resetsAt);
1060
+ if (Number.isNaN(date.getTime())) return "";
1061
+ return translate("subscription.resets", {
1062
+ time: date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
1063
+ });
1064
+ }
1065
+
1066
+ /** Percentage-window body rendered inside the shared provider account frame. */
1067
+ function SubscriptionContent({ provider, translate }) {
1068
+ const windows = Array.isArray(provider.windows) ? provider.windows : [];
1069
+ const status = typeof provider.status === "string" ? provider.status : "unavailable";
1070
+ const emptyMessage = status === "not-configured"
1071
+ ? translate("subscription.notConfigured", { refs: Array.isArray(provider.missingCredentials) ? provider.missingCredentials.join(" + ") : "" })
1072
+ : status === "unauthorized" ? translate("subscription.unauthorized")
1073
+ : status === "rate-limited" ? translate("subscription.rateLimited")
1074
+ : status === "invalid-response" ? translate("account.invalidResponse")
1075
+ : status === "blocked" ? translate("account.blocked")
1076
+ : status === "unsupported" ? translate("balance.unsupported")
1077
+ : translate("subscription.unavailable");
1078
+ return (status === "ok" || provider.stale === true) && windows.length > 0 ? react_jsx_runtime.jsx("div", {
1079
+ className: S.quotaList,
1080
+ children: windows.map((window) => {
1081
+ const used = Math.max(0, Math.min(100, Number(window.usedPercent) || 0));
1082
+ return react_jsx_runtime.jsxs("div", {
1083
+ className: S.quotaRow,
1084
+ children: [
1085
+ react_jsx_runtime.jsxs("div", {
1086
+ className: S.quotaMeta,
1087
+ children: [
1088
+ react_jsx_runtime.jsx("span", { className: S.quotaLabel, children: quotaLabel(window.kind, translate) }),
1089
+ react_jsx_runtime.jsx("span", { className: S.quotaReset, children: resetLabel(window.resetsAt, translate) }),
1090
+ react_jsx_runtime.jsx("span", { className: S.quotaValue, children: translate("subscription.used", { value: used.toFixed(used % 1 === 0 ? 0 : 1) }) })
1091
+ ]
1092
+ }),
1093
+ react_jsx_runtime.jsx("div", {
1094
+ className: S.quotaTrack,
1095
+ role: "progressbar",
1096
+ "aria-label": quotaLabel(window.kind, translate),
1097
+ "aria-valuemin": 0,
1098
+ "aria-valuemax": 100,
1099
+ "aria-valuenow": used,
1100
+ children: react_jsx_runtime.jsx("div", { className: S.quotaFill, style: { width: `${used}%` } })
1101
+ })
1102
+ ]
1103
+ }, window.kind);
1104
+ })
1105
+ }) : react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: emptyMessage });
1106
+ }
1107
+
1108
+ /**
1109
+ * The single account-card interface. Provider identity/colour/status live
1110
+ * in the shared frame; only the inner balance/quota data varies by mode.
1111
+ */
1112
+ function ProviderAccountCard({ provider, account, accountLoading, accountError, translate, onRetry }) {
1113
+ const mode = account?.mode ?? provider.accountMode ?? "balance";
1114
+ const subscriptionMode = mode === "subscription";
1115
+ const status = accountLoading && account === null ? "loading" : account?.status ?? "unavailable";
1116
+ const statusText = status === "loading" ? translate("account.status.loading")
1117
+ : status === "blocked" ? translate("account.status.blocked")
1118
+ : status === "unsupported" ? translate("account.status.unsupported")
1119
+ : subscriptionStatusLabel(status, translate);
1120
+ const subtitle = account?.plan ?? (subscriptionMode ? translate("subscription.planUnknown") : translate("account.balanceMode"));
1121
+ const balanceState = accountLoading && account === null ? "loading"
1122
+ : accountError !== null ? "error"
1123
+ : status === "not-configured" ? "no-credential"
1124
+ : status === "blocked" ? "blocked"
1125
+ : status === "unsupported" ? "unsupported"
1126
+ : account?.balance !== null && account?.balance !== void 0 ? "ok" : "error";
1127
+ const balanceMessage = accountError ?? account?.missingCredentials?.[0] ?? status;
1128
+ const reasonText = diagnosticReasonText(account?.reason, translate);
1129
+ return react_jsx_runtime.jsxs("article", {
1130
+ className: S.accountCard,
1131
+ "data-provider": provider.id,
1132
+ "data-account-mode": mode,
1133
+ children: [
1134
+ react_jsx_runtime.jsxs("div", {
1135
+ className: S.accountHead,
1136
+ children: [
1137
+ react_jsx_runtime.jsx("span", { className: S.accountMark, "aria-hidden": true, children: providerMark(provider) }),
1138
+ react_jsx_runtime.jsxs("span", {
1139
+ className: S.accountIdentity,
1140
+ children: [
1141
+ react_jsx_runtime.jsx("span", { className: S.accountName, children: provider.displayName }),
1142
+ react_jsx_runtime.jsx("span", { className: S.accountPlan, children: subtitle })
1143
+ ]
1144
+ }),
1145
+ react_jsx_runtime.jsx("span", { className: S.accountStatus, "data-status": status, children: statusText })
1146
+ ]
1147
+ }),
1148
+ subscriptionMode
1149
+ ? accountError !== null ? react_jsx_runtime.jsxs("div", {
1150
+ className: S.error,
1151
+ children: [
1152
+ react_jsx_runtime.jsx("span", { children: translate("subscription.error", { message: accountError }) }),
1153
+ react_jsx_runtime.jsx("button", { type: "button", className: S.retry, onClick: onRetry, children: translate("action.retry") })
1154
+ ]
1155
+ }) : accountLoading && account === null
1156
+ ? react_jsx_runtime.jsx("p", { className: S.quotaEmpty, children: translate("subscription.loading") })
1157
+ : react_jsx_runtime.jsx(SubscriptionContent, { provider: account ?? { status: "unavailable", windows: [] }, translate })
1158
+ : react_jsx_runtime.jsx(BalanceContent, { balance: account?.balance ?? null, state: balanceState, message: balanceMessage, translate, onRetry }),
1159
+ reasonText !== null && status !== "ok" ? react_jsx_runtime.jsx("p", { className: S.note, children: reasonText }) : null
1160
+ ]
1161
+ });
1162
+ }
1163
+
1164
+ /**
1165
+ * One day's per-model breakdown. `day` is the wire day entry carrying
1166
+ * `tokens`, `cacheHitRate`, and `models` (descending by tokens).
1167
+ */
1168
+ function DayDetail({ day, translate, onBack }) {
1169
+ const models = Array.isArray(day.models) ? day.models : [];
1170
+ const totalTokens = day.tokens ?? 0;
1171
+ return react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
1172
+ children: [
1173
+ react_jsx_runtime.jsxs("div", {
1174
+ className: S.detailHeader,
1175
+ children: [
1176
+ react_jsx_runtime.jsx("button", {
1177
+ type: "button",
1178
+ className: S.back,
1179
+ "aria-label": translate("usage.back"),
1180
+ onClick: onBack,
1181
+ children: react_jsx_runtime.jsx(primitives.IconChevronLeftOutline14, { size: 14 })
1182
+ }),
1183
+ react_jsx_runtime.jsx("span", { className: S.detailDate, children: dayLabel(day.date, translate) }),
1184
+ react_jsx_runtime.jsx("span", { className: S.detailHit, children: `${translate("usage.hitRate")} ${fmtHit(day.cacheHitRate)}` })
1185
+ ]
1186
+ }),
1187
+ react_jsx_runtime.jsx("p", {
1188
+ className: S.detailSummary,
1189
+ children: `${translate("usage.total")} ${fmt(totalTokens)} · ${translate("usage.input")} ${fmt(day.inputTokens ?? 0)} · ${translate("usage.output")} ${fmt(day.outputTokens ?? 0)} · ${translate("usage.cacheRead")} ${fmt(day.cacheReadTokens ?? 0)}`
1190
+ }),
1191
+ react_jsx_runtime.jsx("div", {
1192
+ className: S.days,
1193
+ children: models.length === 0 ? react_jsx_runtime.jsx("p", { className: S.note, children: translate("usage.noModels") }) : models.map((model) => {
1194
+ const share = totalTokens > 0 ? Math.max(3, Math.round(100 * (model.tokens ?? 0) / totalTokens)) : 0;
1195
+ return react_jsx_runtime.jsxs("div", {
1196
+ className: S.modelRow,
1197
+ children: [
1198
+ react_jsx_runtime.jsxs("div", {
1199
+ className: S.modelHead,
1200
+ children: [
1201
+ react_jsx_runtime.jsx("span", { className: S.modelName, title: model.model, children: modelLabelOf(model.model, translate) }),
1202
+ react_jsx_runtime.jsx("span", { className: S.modelTokens, children: fmt(model.tokens ?? 0) }),
1203
+ react_jsx_runtime.jsx("span", { className: S.modelHit, children: fmtHit(model.cacheHitRate) })
1204
+ ]
1205
+ }),
1206
+ react_jsx_runtime.jsx("div", {
1207
+ className: S.modelBarTrack,
1208
+ children: react_jsx_runtime.jsx("div", { className: S.modelBar, style: { width: `${share}%` } })
1209
+ }),
1210
+ react_jsx_runtime.jsx("div", {
1211
+ className: S.modelMeta,
1212
+ children: `${translate("usage.input")} ${fmt(model.inputTokens ?? 0)} · ${translate("usage.output")} ${fmt(model.outputTokens ?? 0)} · ${translate("usage.cacheRead")} ${fmt(model.cacheReadTokens ?? 0)}`
1213
+ })
1214
+ ]
1215
+ }, model.model);
1216
+ })
1217
+ })
1218
+ ]
1219
+ });
1220
+ }
1221
+
1222
+ /**
1223
+ * Codex-style blue calendar heatmap for one month: weekday header row,
1224
+ * weeks as rows (Mon-first), padded with placeholders. Cells are buttons
1225
+ * that select a day.
1226
+ */
1227
+ function MonthHeatmap({ heat, translate, selectedKey, onSelect }) {
1228
+ const select = typeof onSelect === "function" ? onSelect : () => {};
1229
+ const weekdayLabels = [
1230
+ translate("weekday.mon"),
1231
+ translate("weekday.tue"),
1232
+ translate("weekday.wed"),
1233
+ translate("weekday.thu"),
1234
+ translate("weekday.fri"),
1235
+ translate("weekday.sat"),
1236
+ translate("weekday.sun")
1237
+ ];
1238
+ return react_jsx_runtime.jsxs("div", {
1239
+ className: S.heat,
1240
+ children: [
1241
+ react_jsx_runtime.jsxs("div", {
1242
+ className: S.monthGrid,
1243
+ children: [
1244
+ react_jsx_runtime.jsx("div", {
1245
+ className: S.weekHeader,
1246
+ children: weekdayLabels.map((label) => react_jsx_runtime.jsx("span", { className: S.weekLabel, children: label }, label))
1247
+ }),
1248
+ heat.weeks.map((week, weekIndex) => react_jsx_runtime.jsx("div", {
1249
+ className: S.heatRow,
1250
+ children: week.map((cell, dayIndex) => {
1251
+ if (cell === null) return react_jsx_runtime.jsx("span", { className: S.emptyCell, "aria-hidden": true }, `${weekIndex}-${dayIndex}`);
1252
+ const style = cellColor(cell.tokens, heat.max);
1253
+ const hit = cell.hitRate === null || cell.hitRate === void 0 ? "" : ` · ${translate("usage.hitRate")} ${cell.hitRate}%`;
1254
+ const isToday = cell.key === todayKey();
1255
+ return react_jsx_runtime.jsx("button", {
1256
+ type: "button",
1257
+ className: `${S.cell}${isToday ? ` ${S.cellToday}` : ""}${selectedKey === cell.key ? ` ${S.cellSelected}` : ""}`,
1258
+ style: { background: style.background, color: style.color },
1259
+ title: `${cell.key} · ${fmt(cell.tokens)} tokens${hit}`,
1260
+ "aria-label": `${cell.key} · ${fmt(cell.tokens)} tokens`,
1261
+ onClick: () => select(cell.key),
1262
+ children: react_jsx_runtime.jsx("span", { className: S.cellDay, children: cell.day })
1263
+ }, cell.key);
1264
+ })
1265
+ }, weekIndex))
1266
+ ]
1267
+ }),
1268
+ react_jsx_runtime.jsxs("div", {
1269
+ className: S.legend,
1270
+ children: [
1271
+ react_jsx_runtime.jsx("span", { children: translate("usage.legendLess") }),
1272
+ [0.22, 0.42, 0.6, 0.8, 1].map((alpha, index) => react_jsx_runtime.jsx("span", {
1273
+ className: S.legendSwatch,
1274
+ style: { background: `rgba(${BLUE_RGB[0]}, ${BLUE_RGB[1]}, ${BLUE_RGB[2]}, ${alpha})` }
1275
+ }, index)),
1276
+ react_jsx_runtime.jsx("span", { children: translate("usage.legendMore") })
1277
+ ]
1278
+ })
1279
+ ]
1280
+ });
1281
+ }
1282
+
1283
+ /** `YYYY-MM-DD` → `MM-DD 周X` display label. */
1284
+ function dayLabel(key, translate) {
1285
+ const [, month, day] = key.split("-");
1286
+ const date = new Date(Number(key.slice(0, 4)), Number(month) - 1, Number(day));
1287
+ const weekdays = [translate("weekday.sun"), translate("weekday.mon"), translate("weekday.tue"), translate("weekday.wed"), translate("weekday.thu"), translate("weekday.fri"), translate("weekday.sat")];
1288
+ return `${month}-${day} ${weekdays[date.getDay()]}`;
1289
+ }
1290
+
1291
+ function monthName(month, translate) {
1292
+ const names = translate("month.names").split(",");
1293
+ return names[month] ?? String(month + 1);
1294
+ }
1295
+
1296
+ /**
1297
+ * Display label for a `provider/model` attribution key (the same model
1298
+ * served by different providers must stay distinguishable).
1299
+ */
1300
+ function modelLabelOf(key, translate) {
1301
+ if (typeof key !== "string") return "";
1302
+ const slash = key.indexOf("/");
1303
+ if (slash === -1) return key;
1304
+ const provider = key.slice(0, slash);
1305
+ const model = key.slice(slash + 1);
1306
+ const providerLabel = provider === "unknown" ? translate("usage.unknownModel") : provider;
1307
+ const modelLabel = model === "unknown" || model === "" ? translate("usage.unknownModel") : model;
1308
+ return `${providerLabel} · ${modelLabel}`;
1309
+ }
1310
+ //#endregion
1311
+
1312
+ //#region locales
1313
+ /** `usageStats` namespace dictionaries (the zh key set is the source of truth). */
1314
+ const NS = "usageStats";
1315
+ const zh = {
1316
+ "panel.title": "用量与余额",
1317
+ "panel.badge": "用量/余额",
1318
+ "account.title": "供应商账户",
1319
+ "account.provider": "当前供应商",
1320
+ "account.balanceMode": "API 余额",
1321
+ "account.loading": "正在加载供应商…",
1322
+ "account.status.loading": "查询中",
1323
+ "account.status.blocked": "已阻止",
1324
+ "account.status.unsupported": "不支持余额",
1325
+ "account.status.invalidResponse": "响应异常",
1326
+ "account.invalidResponse": "供应商返回了无法识别的额度数据。",
1327
+ "account.reason.dnsResolutionFailed": "无法解析供应商域名。",
1328
+ "account.reason.allAddressesUnreachable": "已验证的网络地址均无法连接。",
1329
+ "account.reason.upstreamNotJson": "上游返回的不是 JSON",
1330
+ "account.reason.upstreamInvalidJson": "上游返回了无法解析的 JSON",
1331
+ "account.reason.sub2apiBalanceShapeUnrecognized": "Sub2API 余额接口返回了未识别的数据结构。",
1332
+ "account.blocked": "账户查询被本地安全策略阻止,请检查 HTTPS、同源和私网访问设置。",
1333
+ "balance.title": "账户余额",
1334
+ "balance.provider": "供应商",
1335
+ "balance.noSchemeTag": "无余额接口",
1336
+ "balance.unsupported": "该供应商没有公开的余额查询接口。",
1337
+ "balance.total": "总余额",
1338
+ "balance.remaining": "可用余额",
1339
+ "balance.used": "已使用",
1340
+ "balance.toppedUp": "充值余额",
1341
+ "balance.granted": "赠送余额",
1342
+ "balance.available": "可用",
1343
+ "balance.unavailable": "不可用",
1344
+ "balance.loading": "正在查询余额…",
1345
+ "balance.noCredential": "未配置 {ref}(请编辑 ~/.dsh/.credentials.yaml)",
1346
+ "balance.error": "余额获取失败:{message}",
1347
+ "subscription.title": "订阅额度",
1348
+ "subscription.loading": "正在查询订阅额度…",
1349
+ "subscription.error": "订阅额度获取失败:{message}",
1350
+ "subscription.status.ok": "实时",
1351
+ "subscription.status.notConfigured": "未配置",
1352
+ "subscription.status.unauthorized": "需重新登录",
1353
+ "subscription.status.rateLimited": "请求受限",
1354
+ "subscription.status.unavailable": "暂不可用",
1355
+ "subscription.window.session": "5 小时窗口",
1356
+ "subscription.window.daily": "每日窗口",
1357
+ "subscription.window.weekly": "每周窗口",
1358
+ "subscription.window.monthly": "每月窗口",
1359
+ "subscription.window.quota": "总额度",
1360
+ "subscription.window.mcp": "MCP 月度额度",
1361
+ "subscription.used": "已用 {value}%",
1362
+ "subscription.resets": "{time} 重置",
1363
+ "subscription.notConfigured": "配置 {refs} 后显示真实订阅比例。",
1364
+ "subscription.unauthorized": "凭据已失效,请更新后重试。",
1365
+ "subscription.rateLimited": "供应商暂时限制查询,请稍后重试。",
1366
+ "subscription.unavailable": "供应商没有返回可识别的额度窗口。",
1367
+ "subscription.planUnknown": "订阅计划",
1368
+ "usage.title": "Token 用量",
1369
+ "usage.today": "今日",
1370
+ "usage.month": "本月",
1371
+ "usage.total": "累计",
1372
+ "usage.loading": "正在统计用量…",
1373
+ "usage.error": "用量统计失败:{message}",
1374
+ "usage.heatmap": "当月每日用量",
1375
+ "usage.recent": "最近 14 天",
1376
+ "usage.legendLess": "少",
1377
+ "usage.legendMore": "多",
1378
+ "usage.back": "返回",
1379
+ "usage.hitRate": "缓存命中",
1380
+ "usage.hit.today": "今日缓存命中率",
1381
+ "usage.input": "输入",
1382
+ "usage.output": "输出",
1383
+ "usage.cacheRead": "缓存读",
1384
+ "usage.unknownModel": "未知模型",
1385
+ "usage.noModels": "这一天没有分模型数据。",
1386
+ "month.year": "{year}年{month}",
1387
+ "action.refresh": "刷新",
1388
+ "action.retry": "重试",
1389
+ "action.close": "关闭",
1390
+ "action.prevMonth": "上个月",
1391
+ "action.nextMonth": "下个月",
1392
+ "action.today": "回到今天",
1393
+ "panel.updatedAt": "更新于 {time}",
1394
+ "weekday.mon": "一",
1395
+ "weekday.tue": "二",
1396
+ "weekday.wed": "三",
1397
+ "weekday.thu": "四",
1398
+ "weekday.fri": "五",
1399
+ "weekday.sat": "六",
1400
+ "weekday.sun": "日",
1401
+ "month.names": "1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月"
1402
+ };
1403
+ const en = {
1404
+ "panel.title": "Usage & Balance",
1405
+ "panel.badge": "Usage/Balance",
1406
+ "account.title": "Provider account",
1407
+ "account.provider": "Current provider",
1408
+ "account.balanceMode": "API balance",
1409
+ "account.loading": "Loading providers…",
1410
+ "account.status.loading": "Loading",
1411
+ "account.status.blocked": "Blocked",
1412
+ "account.status.unsupported": "Balance unsupported",
1413
+ "account.status.invalidResponse": "Invalid response",
1414
+ "account.invalidResponse": "The provider returned unrecognized quota data.",
1415
+ "account.reason.dnsResolutionFailed": "The provider hostname could not be resolved.",
1416
+ "account.reason.allAddressesUnreachable": "None of the validated network addresses could be reached.",
1417
+ "account.reason.upstreamNotJson": "The upstream response was not JSON",
1418
+ "account.reason.upstreamInvalidJson": "The upstream returned unparsable JSON",
1419
+ "account.reason.sub2apiBalanceShapeUnrecognized": "The Sub2API balance endpoint returned an unrecognized data shape.",
1420
+ "account.blocked": "The account query was blocked by the local security policy. Check HTTPS, same-origin, and private-network settings.",
1421
+ "balance.title": "Account balance",
1422
+ "balance.provider": "Provider",
1423
+ "balance.noSchemeTag": "no balance API",
1424
+ "balance.unsupported": "This provider has no public balance interface.",
1425
+ "balance.total": "Total balance",
1426
+ "balance.remaining": "Available balance",
1427
+ "balance.used": "Used",
1428
+ "balance.toppedUp": "Topped up",
1429
+ "balance.granted": "Granted",
1430
+ "balance.available": "available",
1431
+ "balance.unavailable": "unavailable",
1432
+ "balance.loading": "Fetching balance…",
1433
+ "balance.noCredential": "{ref} is not configured (edit ~/.dsh/.credentials.yaml)",
1434
+ "balance.error": "Balance fetch failed: {message}",
1435
+ "subscription.title": "Subscription usage",
1436
+ "subscription.loading": "Fetching subscription usage…",
1437
+ "subscription.error": "Subscription usage failed: {message}",
1438
+ "subscription.status.ok": "Live",
1439
+ "subscription.status.notConfigured": "Not configured",
1440
+ "subscription.status.unauthorized": "Sign in again",
1441
+ "subscription.status.rateLimited": "Rate limited",
1442
+ "subscription.status.unavailable": "Unavailable",
1443
+ "subscription.window.session": "5-hour window",
1444
+ "subscription.window.daily": "Daily window",
1445
+ "subscription.window.weekly": "Weekly window",
1446
+ "subscription.window.monthly": "Monthly window",
1447
+ "subscription.window.quota": "Total quota",
1448
+ "subscription.window.mcp": "Monthly MCP quota",
1449
+ "subscription.used": "{value}% used",
1450
+ "subscription.resets": "Resets {time}",
1451
+ "subscription.notConfigured": "Configure {refs} to show live subscription usage.",
1452
+ "subscription.unauthorized": "The credential has expired; update it and retry.",
1453
+ "subscription.rateLimited": "The provider is rate limiting checks; retry later.",
1454
+ "subscription.unavailable": "The provider returned no recognizable quota windows.",
1455
+ "subscription.planUnknown": "Subscription plan",
1456
+ "usage.title": "Token usage",
1457
+ "usage.today": "Today",
1458
+ "usage.month": "This month",
1459
+ "usage.total": "All time",
1460
+ "usage.loading": "Aggregating usage…",
1461
+ "usage.error": "Usage aggregation failed: {message}",
1462
+ "usage.heatmap": "Daily usage this month",
1463
+ "usage.recent": "Last 14 days",
1464
+ "usage.legendLess": "Less",
1465
+ "usage.legendMore": "More",
1466
+ "usage.back": "Back",
1467
+ "usage.hitRate": "Cache hit",
1468
+ "usage.hit.today": "Today's cache hit rate",
1469
+ "usage.input": "Input",
1470
+ "usage.output": "Output",
1471
+ "usage.cacheRead": "Cache read",
1472
+ "usage.unknownModel": "Unknown model",
1473
+ "usage.noModels": "No per-model data for this day.",
1474
+ "month.year": "{month} {year}",
1475
+ "action.refresh": "Refresh",
1476
+ "action.retry": "Retry",
1477
+ "action.close": "Close",
1478
+ "action.prevMonth": "Previous month",
1479
+ "action.nextMonth": "Next month",
1480
+ "action.today": "Today",
1481
+ "panel.updatedAt": "Updated at {time}",
1482
+ "weekday.mon": "M",
1483
+ "weekday.tue": "T",
1484
+ "weekday.wed": "W",
1485
+ "weekday.thu": "T",
1486
+ "weekday.fri": "F",
1487
+ "weekday.sat": "S",
1488
+ "weekday.sun": "S",
1489
+ "month.names": "Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec"
1490
+ };
1491
+ //#endregion
1492
+
1493
+ //#region plugin body
1494
+ /** Services required by the client plugin body. */
1495
+ const inject = ["slots", "locale"];
1496
+
1497
+ /**
1498
+ * Client plugin body: register the dictionaries and the sidebar footer action.
1499
+ * @param ctx - client root context.
1500
+ */
1501
+ function apply(ctx) {
1502
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "usage-stats: dictionaries");
1503
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
1504
+ name: "sidebar.footer.action",
1505
+ id: "usage-stats",
1506
+ locale: NS,
1507
+ order: 10
1508
+ }, UsageStatsPanel));
1509
+ }
1510
+ //#endregion
1511
+
1512
+ exports.apply = apply;
1513
+ exports.inject = inject;
1514
+ exports.UsageStatsPanel = UsageStatsPanel;
1515
+ exports.DayDetail = DayDetail;
1516
+ exports.ProviderAccountCard = ProviderAccountCard;
1517
+ exports.MonthHeatmap = MonthHeatmap;
1518
+ exports.buildMonthHeatmap = buildMonthHeatmap;
1519
+ exports.cellColor = cellColor;
1520
+ exports.createLoader = createLoader;
1521
+ exports.buildProviderChoices = buildProviderChoices;
1522
+ exports.modelLabelOf = modelLabelOf;
1523
+ exports.fmt = fmt;
1524
+ exports.fmtCurrency = fmtCurrency;
1525
+ exports.badgeAccountValue = badgeAccountValue;
1526
+ exports.badgeWarnOf = badgeWarnOf;
1527
+ exports.shouldDismissPanel = shouldDismissPanel;
1528
+ exports.safeDiagnosticReason = safeDiagnosticReason;
1529
+ return module.exports;
1530
+ }
1531
+ });