@kylindc/ccxray 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ // ── Cost Budget: Client-side ──────────────────────────────────────────
2
+ let _row2Block = null, _row2Monthly = null;
3
+ function updateRow2Usage(block, monthly) {
4
+ if (block) _row2Block = block;
5
+ if (monthly) _row2Monthly = monthly;
6
+ const el = document.getElementById('row2-usage-summary');
7
+ if (!el) return;
8
+ const parts = [];
9
+ if (_row2Block && _row2Block.active) {
10
+ parts.push('Window: ' + (_row2Block.percentUsed || 0).toFixed(0) + '% used');
11
+ }
12
+ if (_row2Monthly && _row2Monthly.currentMonth) {
13
+ parts.push('$' + (_row2Monthly.currentMonth.costUSD || 0).toFixed(2) + ' this month');
14
+ }
15
+ el.textContent = parts.join(' · ') || 'Loading…';
16
+ }
17
+
18
+ function showCostPage() {
19
+ switchTab('usage');
20
+ }
21
+ function hideCostPage() {
22
+ switchTab('dashboard');
23
+ }
24
+
25
+ async function loadCostPage() {
26
+ const z1 = document.getElementById('cp-z1-content');
27
+ const z2 = document.getElementById('cp-z2-content');
28
+ const z3 = document.getElementById('cp-z3-content');
29
+ const spinner = '<div style="color:var(--dim);padding:20px;text-align:center">Loading usage data…</div>';
30
+ z1.innerHTML = spinner;
31
+ z2.innerHTML = spinner;
32
+ z3.innerHTML = spinner;
33
+
34
+ // Poll until data is ready (server returns 202 while computing)
35
+ async function fetchWithRetry(url, fallback, maxRetries = 20) {
36
+ for (let i = 0; i < maxRetries; i++) {
37
+ try {
38
+ const r = await fetch(url);
39
+ const data = await r.json();
40
+ if (data && data.loading) {
41
+ await new Promise(ok => setTimeout(ok, 2000));
42
+ continue;
43
+ }
44
+ return data;
45
+ } catch { return fallback; }
46
+ }
47
+ return fallback;
48
+ }
49
+
50
+ // Each zone fetches and renders independently — no Promise.all
51
+ fetchWithRetry('/_api/costs/current-block', { active: false })
52
+ .then(blockData => { renderZone1(blockData); updateRow2Usage(blockData, null); });
53
+
54
+ const dailyPromise = fetchWithRetry('/_api/costs/daily', []);
55
+ const monthlyPromise = fetchWithRetry('/_api/costs/monthly', { monthly: [], currentMonth: { costUSD: 0 } });
56
+
57
+ // Zone 2 needs both monthly and daily data
58
+ Promise.all([monthlyPromise, dailyPromise])
59
+ .then(([monthlyData, dailyData]) => { renderZone2(monthlyData, dailyData); updateRow2Usage(null, monthlyData); });
60
+
61
+ // Zone 3 only needs daily data
62
+ dailyPromise.then(dailyData => renderZone3(dailyData));
63
+ }
64
+
65
+ function renderZone1(block) {
66
+ const el = document.getElementById('cp-z1-content');
67
+ if (!block.active) {
68
+ const lb = block.lastBlock;
69
+ if (lb) {
70
+ const agoH = Math.floor(lb.minutesAgo / 60), agoM = lb.minutesAgo % 60;
71
+ const agoStr = agoH > 0 ? agoH + 'h ' + agoM + 'min ago' : agoM + 'min ago';
72
+ el.innerHTML = `
73
+ <div style="color:var(--dim);font-size:12px;margin-bottom:6px">No active window · last ended ${agoStr}</div>
74
+ <div style="font-size:11px;color:var(--dim)">
75
+ ${lb.totalTokens.toLocaleString()} tokens · $${lb.costUSD} · ${lb.models.slice(0,2).map(m=>m.split('-')[1]).join('/')}
76
+ </div>
77
+ `;
78
+ } else {
79
+ el.innerHTML = '<div style="color:var(--dim);font-size:12px">No history data</div>';
80
+ }
81
+ return;
82
+ }
83
+
84
+ const pct = block.percentUsed || 0;
85
+ const timePct = block.timePct || 0;
86
+ const tokenColor = pct < 60 ? 'var(--green)' : pct < 85 ? 'var(--yellow)' : 'var(--red)';
87
+ const paceRatio = timePct > 0 ? pct / timePct : 0;
88
+ let statusDot, statusMsg, statusColor;
89
+ if (paceRatio > 1.3) {
90
+ statusDot = '🔴'; statusMsg = 'Burning faster than time'; statusColor = 'var(--red)';
91
+ } else if (paceRatio > 1.1) {
92
+ statusDot = '🟡'; statusMsg = 'Slightly fast, watch it'; statusColor = 'var(--yellow)';
93
+ } else if (paceRatio < 0.7 && timePct > 20) {
94
+ statusDot = '🟢'; statusMsg = 'Quota comfortable'; statusColor = 'var(--green)';
95
+ } else {
96
+ statusDot = '🟢'; statusMsg = 'Rate normal'; statusColor = 'var(--green)';
97
+ }
98
+
99
+ const minRemaining = block.minutesRemaining || 0;
100
+ const remainH = Math.floor(minRemaining / 60), remainM = minRemaining % 60;
101
+ const remainStr = remainH > 0 ? remainH + 'h ' + remainM + 'm' : remainM + 'm';
102
+ const br = block.burnRate;
103
+ const proj = block.projection;
104
+
105
+ el.innerHTML = `
106
+ <div style="font-size:12px;font-weight:600;color:${statusColor};margin-bottom:10px">${statusDot} ${statusMsg}</div>
107
+ <div style="margin-bottom:8px">
108
+ <div style="display:flex;justify-content:space-between;font-size:10px;color:var(--dim);margin-bottom:2px">
109
+ <span>TOKENS</span><span style="color:${tokenColor}">${pct.toFixed(1)}% · ${(block.totalTokens/1000).toFixed(0)}k / ${((block.tokenLimit||220000)/1000).toFixed(0)}k${pct>100?' ⚠️':''}</span>
110
+ </div>
111
+ <div style="height:7px;background:var(--border);border-radius:3px;overflow:hidden;margin-bottom:6px">
112
+ <div style="height:100%;width:${Math.min(pct,100)}%;background:${tokenColor};border-radius:3px;transition:width 0.5s"></div>
113
+ </div>
114
+ <div style="display:flex;justify-content:space-between;font-size:10px;color:var(--dim);margin-bottom:2px">
115
+ <span>TIME</span><span>${timePct.toFixed(1)}% · ${remainStr} left</span>
116
+ </div>
117
+ <div style="height:7px;background:var(--border);border-radius:3px;overflow:hidden">
118
+ <div style="height:100%;width:${Math.min(timePct,100)}%;background:var(--dim);border-radius:3px;opacity:0.5;transition:width 0.5s"></div>
119
+ </div>
120
+ </div>
121
+ ${br ? `<div style="font-size:10px;color:var(--dim);border-top:1px solid var(--border);padding-top:7px;display:flex;gap:12px;flex-wrap:wrap">
122
+ <span>${br.tokensPerMinute.toLocaleString()} tok/min</span>
123
+ <span>$${br.costPerHour}/hr</span>
124
+ ${proj ? `<span>proj ${(proj.totalTokens/1000).toFixed(0)}k ($${proj.totalCost})</span>` : ''}
125
+ <span style="margin-left:auto">$${block.costUSD} equiv.</span>
126
+ </div>` : `<div style="font-size:10px;color:var(--dim)">$${block.costUSD} equiv. API cost</div>`}
127
+ `;
128
+ }
129
+
130
+ const PLAN_OPTIONS = [
131
+ { label: 'Pro $20/mo', price: 20 },
132
+ { label: 'Max 5x $100/mo', price: 100 },
133
+ { label: 'Max 20x $200/mo', price: 200 },
134
+ ];
135
+
136
+ function getSelectedPlanPrice() {
137
+ return parseInt(localStorage.getItem('planPrice')) || 200;
138
+ }
139
+
140
+ function renderZone2(monthlyData, dailyData) {
141
+ const el = document.getElementById('cp-z2-content');
142
+ const currentCost = monthlyData.currentMonth?.costUSD || 0;
143
+ const planPrice = getSelectedPlanPrice();
144
+ const roi = (currentCost / planPrice).toFixed(1);
145
+ const saved = (currentCost - planPrice).toFixed(0);
146
+ const roiColor = parseFloat(roi) >= 1 ? 'var(--green)' : 'var(--yellow)';
147
+
148
+ const optionsHtml = PLAN_OPTIONS.map(p =>
149
+ `<option value="${p.price}"${p.price === planPrice ? ' selected' : ''}>${p.label}</option>`
150
+ ).join('');
151
+
152
+ // Render plan selector in card label
153
+ const labelEl = document.getElementById('cp-z2-label');
154
+ if (labelEl) {
155
+ labelEl.innerHTML = `ROI &amp; Plan Fit <select onchange="localStorage.setItem('planPrice',this.value);renderZone2(window._zone2Monthly,window._zone2Daily)" style="background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:3px;font-size:10px;padding:1px 4px;cursor:pointer">${optionsHtml}</select>`;
156
+ }
157
+
158
+ el.innerHTML = `
159
+ <div style="display:flex;gap:20px;flex-wrap:wrap">
160
+ <div>
161
+ <div style="font-size:10px;color:var(--dim);margin-bottom:2px">Equiv. API cost this month (not actual spend)</div>
162
+ <div style="font-size:20px;font-weight:700;color:${roiColor}">$${currentCost.toFixed(2)}</div>
163
+ </div>
164
+ <div>
165
+ <div style="font-size:10px;color:var(--dim);margin-bottom:2px">Monthly ROI</div>
166
+ <div style="font-size:20px;font-weight:700;color:${roiColor}">${roi}x</div>
167
+ </div>
168
+ ${parseFloat(saved) > 0 ? `<div>
169
+ <div style="font-size:10px;color:var(--dim);margin-bottom:2px">Saved</div>
170
+ <div style="font-size:20px;font-weight:700;color:var(--green)">$${saved}</div>
171
+ </div>` : ''}
172
+ </div>
173
+ `;
174
+
175
+ // Store data for re-render on plan change
176
+ window._zone2Monthly = monthlyData;
177
+ window._zone2Daily = dailyData;
178
+ }
179
+
180
+ let zone3Metric = 'sessions'; // 'sessions' | 'tokens'
181
+
182
+ function renderZone3(dailyData) {
183
+ const container = document.getElementById('cp-z3-content');
184
+ if (!dailyData || !dailyData.length) {
185
+ container.innerHTML = '<div style="color:var(--dim);font-size:12px">No data</div>';
186
+ return;
187
+ }
188
+ window._zone3Data = dailyData; // store for toggle
189
+
190
+ // Summary totals
191
+ const totalSessions = dailyData.reduce((s, d) => s + (d.sessionCount || 0), 0);
192
+ const totalTokens = dailyData.reduce((s, d) => s + (d.totalTokens || 0), 0);
193
+ const tokStr = totalTokens >= 1e9 ? (totalTokens/1e9).toFixed(1)+'B'
194
+ : totalTokens >= 1e6 ? (totalTokens/1e6).toFixed(1)+'M'
195
+ : totalTokens >= 1e3 ? (totalTokens/1e3).toFixed(0)+'k'
196
+ : String(totalTokens);
197
+
198
+ const isSessions = zone3Metric === 'sessions';
199
+ const summaryHtml = `
200
+ <div style="display:flex;gap:24px;margin-bottom:10px">
201
+ <div style="cursor:pointer" onclick="zone3Metric='sessions';renderZone3(window._zone3Data)">
202
+ <div style="font-size:15px;font-weight:700;color:${isSessions?'var(--text)':'var(--dim)'}">${totalSessions.toLocaleString()} sessions</div>
203
+ <div style="height:2px;background:${isSessions?'var(--text)':'transparent'};border-radius:1px;margin-top:2px"></div>
204
+ </div>
205
+ <div style="cursor:pointer" onclick="zone3Metric='tokens';renderZone3(window._zone3Data)">
206
+ <div style="font-size:15px;font-weight:700;color:${!isSessions?'var(--text)':'var(--dim)'}">${tokStr} tokens</div>
207
+ <div style="height:2px;background:${!isSessions?'var(--text)':'transparent'};border-radius:1px;margin-top:2px"></div>
208
+ </div>
209
+ <div style="font-size:11px;color:var(--dim);align-self:flex-end;margin-bottom:3px">Last 3 months</div>
210
+ </div>
211
+ `;
212
+
213
+ // Build a map of date → value
214
+ const costByDate = {};
215
+ for (const d of dailyData) costByDate[d.date] = isSessions ? (d.sessionCount || 0) : (d.totalTokens || 0);
216
+
217
+ // 6 levels via p17/p33/p50/p67/p83 of non-zero days — thresholds follow current metric
218
+ const metricValues = dailyData.map(d => isSessions ? (d.sessionCount || 0) : (d.totalTokens || 0));
219
+ const nonZero = metricValues.filter(c => c > 0).sort((a,b)=>a-b);
220
+ const p = (arr, pct) => arr[Math.max(0, Math.floor(arr.length * pct) - 1)] || 0;
221
+ const [t1,t2,t3,t4,t5] = [0.17,0.33,0.50,0.67,0.83].map(q => p(nonZero, q));
222
+ const cs = getComputedStyle(document.documentElement);
223
+ const PALETTE = [0,1,2,3,4,5].map(i => cs.getPropertyValue('--heatmap-' + i).trim());
224
+ const HEATMAP_EMPTY = cs.getPropertyValue('--heatmap-empty').trim();
225
+ function cellColor(cost) {
226
+ if (cost <= 0) return 'var(--border)';
227
+ if (cost < t1) return PALETTE[0];
228
+ if (cost < t2) return PALETTE[1];
229
+ if (cost < t3) return PALETTE[2];
230
+ if (cost < t4) return PALETTE[3];
231
+ if (cost < t5) return PALETTE[4];
232
+ return PALETTE[5];
233
+ }
234
+
235
+ // Grid aligned to Sunday (row 0 = Sun, row 6 = Sat)
236
+ const today = new Date();
237
+ const thisSunday = new Date(today);
238
+ thisSunday.setDate(today.getDate() - today.getDay()); // getDay(): 0=Sun
239
+ thisSunday.setHours(0,0,0,0);
240
+
241
+ const WEEKS = 26; // ~6 months
242
+ const gridStart = new Date(thisSunday);
243
+ gridStart.setDate(thisSunday.getDate() - (WEEKS - 1) * 7);
244
+
245
+ const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
246
+ const CS = 11, GAP = 2, LW = 26; // cell size, gap, label width
247
+ const cellStyle = `width:${CS}px;height:${CS}px;border-radius:3px;display:inline-block;flex-shrink:0;`;
248
+
249
+ // Month labels: first week of each month
250
+ const monthLabels = [];
251
+ let lastMonth = -1;
252
+ for (let w = 0; w < WEEKS; w++) {
253
+ const d = new Date(gridStart);
254
+ d.setDate(gridStart.getDate() + w * 7);
255
+ if (d.getMonth() !== lastMonth) {
256
+ monthLabels.push({ w, label: d.toLocaleString('en', { month: 'short' }) });
257
+ lastMonth = d.getMonth();
258
+ }
259
+ }
260
+
261
+ let html = summaryHtml + `<div style="overflow-x:auto;overflow-y:hidden">`;
262
+
263
+ // Month header — GitHub style: label above first week of each month
264
+ html += `<div style="position:relative;height:14px;margin-left:${LW}px;margin-bottom:3px">`;
265
+ for (const { w, label } of monthLabels) {
266
+ html += `<div style="position:absolute;left:${w*(CS+GAP)}px;font-size:10px;color:var(--text);opacity:0.7;white-space:nowrap">${label}</div>`;
267
+ }
268
+ html += '</div>';
269
+
270
+ // Day rows: Sun(0) top → Sat(6) bottom; GitHub labels Mon/Wed/Fri only
271
+ const SHOW_LABEL = new Set([1, 3, 5]); // Mon, Wed, Fri
272
+ for (let dow = 0; dow < 7; dow++) {
273
+ html += `<div style="display:flex;align-items:center;gap:${GAP}px;margin-bottom:${GAP}px">`;
274
+ const rowLabel = SHOW_LABEL.has(dow) ? dayNames[dow] : '';
275
+ html += `<div style="width:${LW}px;font-size:9px;color:var(--dim);text-align:right;padding-right:5px;flex-shrink:0">${rowLabel}</div>`;
276
+ for (let w = 0; w < WEEKS; w++) {
277
+ const d = new Date(gridStart);
278
+ d.setDate(gridStart.getDate() + w * 7 + dow);
279
+ const dateStr = d.toLocaleDateString('sv-SE');
280
+ const cost = costByDate[dateStr] || 0;
281
+ const isFuture = d > today;
282
+ const bg = isFuture ? 'var(--border)' : (cost === 0 ? '${HEATMAP_EMPTY}' : cellColor(cost));
283
+ const isWeekend = dow === 0 || dow === 6;
284
+ const opacity = (isWeekend && cost === 0 && !isFuture) ? '0.4' : '1';
285
+ const valStr = isSessions ? cost + ' sessions' : (cost >= 1000 ? (cost/1000).toFixed(1)+'k' : cost) + ' tokens';
286
+ const title = isFuture ? dateStr : `${dateStr} ${valStr}`;
287
+ html += `<div style="${cellStyle}background:${bg};opacity:${opacity}" title="${title}"></div>`;
288
+ }
289
+ html += '</div>';
290
+ }
291
+
292
+ // Legend — GitHub style: Less ◻◻◻◻◻ More, right-aligned
293
+ html += `<div style="display:flex;gap:4px;align-items:center;margin-top:8px;margin-left:${LW}px;font-size:10px;color:var(--dim);justify-content:flex-end">
294
+ <span>Less</span>
295
+ <div style="${cellStyle}background:${HEATMAP_EMPTY}"></div>
296
+ ${PALETTE.map(c=>`<div style="${cellStyle}background:${c}"></div>`).join('')}
297
+ <span>More</span>
298
+ </div>`;
299
+ html += '</div>';
300
+
301
+ container.innerHTML = html;
302
+ }
303
+
304
+
305
+ // Escape handler moved to unified fullscreen-page listener in app.js