@krtclcdy/tersio 2.20.1 → 2.22.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.
@@ -1,193 +1,9 @@
1
1
  (function() {
2
- var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
3
- var hasGsap = !reduce && typeof window.gsap !== 'undefined';
4
- var hasST = hasGsap && typeof window.ScrollTrigger !== 'undefined';
5
- if (hasST) gsap.registerPlugin(ScrollTrigger);
6
-
7
- function fmt(n) { return Number(n).toLocaleString('en-US'); }
8
- function fmtShort(n) {
9
- if (n >= 1e12) return (n / 1e12).toFixed(1) + 'T';
10
- if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
11
- if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
12
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
13
- return String(Math.round(n));
14
- }
15
-
16
- var CURS = {
17
- USD: { s: '$', d: 2 }, PHP: { s: '₱', d: 2 }, EUR: { s: '€', d: 2 }, GBP: { s: '£', d: 2 },
18
- JPY: { s: '¥', d: 0 }, KRW: { s: '₩', d: 0 }, SGD: { s: 'S$', d: 2 }, AUD: { s: 'A$', d: 2 },
19
- CAD: { s: 'C$', d: 2 }, INR: { s: '₹', d: 2 }
20
- };
21
- var FLAGS = { USD: '🇺🇸', PHP: '🇵🇭', EUR: '🇪🇺', GBP: '🇬🇧', JPY: '🇯🇵', KRW: '🇰🇷', SGD: '🇸🇬', AUD: '🇦🇺', CAD: '🇨🇦', INR: '🇮🇳' };
22
- // ponytail: static snapshot; live rates from frankfurter replace it when reachable
23
- var fx = { cur: 'USD', rates: { USD: 1, PHP: 58.7, EUR: 0.92, GBP: 0.79, JPY: 149.8, KRW: 1385, SGD: 1.34, AUD: 1.52, CAD: 1.37, INR: 88.2 }, live: false };
24
- try {
25
- var savedCur = localStorage.getItem('tersio-fx-cur');
26
- if (savedCur && CURS[savedCur]) fx.cur = savedCur;
27
- var savedFx = JSON.parse(localStorage.getItem('tersio-fx') || 'null');
28
- if (savedFx && savedFx.rates && savedFx.rates.USD === 1) fx.rates = savedFx.rates;
29
- } catch (e) { }
30
- // Magnitude-aware decimals. Model prices keep falling, so a flat 2dp rounds
31
- // real spend down to "$0.00"; small amounts keep enough digits to stay
32
- // readable (0.000187, not 0). Larger amounts use the currency's own scale.
33
- function moneyDecimals(v, base) {
34
- var a = Math.abs(v);
35
- if (a >= 0.1 || a === 0) return base;
36
- if (a >= 0.001) return Math.max(base, 4);
37
- if (a >= 0.00001) return Math.max(base, 6);
38
- return Math.max(base, 8);
39
- }
40
- function fxMoney(v) {
41
- var c = CURS[fx.cur] || CURS.USD, r = fx.rates[fx.cur] || 1, amt = v * r;
42
- var d = moneyDecimals(amt, c.d);
43
- return c.s + amt.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
44
- }
45
- // Tooltips build innerHTML, and error notes come from providers, so they are
46
- // never interpolated raw.
47
- function esc(s) {
48
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49
- }
50
-
51
- function countUp(el, target, money) {
52
- function text(v) { return money ? fxMoney(v) : fmt(Math.round(v)); }
53
- if (!hasGsap || target <= 0) { el.textContent = text(target); return; }
54
- var o = { v: 0 };
55
- gsap.to(o, { v: target, duration: 1.1, ease: 'power3.out', onUpdate: function() { el.textContent = text(o.v); } });
56
- }
57
-
58
- function tick() {
59
- var c = document.getElementById('clock');
60
- if (c) c.textContent = new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
61
- }
62
- tick(); setInterval(tick, 1000);
63
- function currentTheme() {
64
- try { return localStorage.getItem('tersio-theme') || 'system'; } catch (e) { return 'system'; }
65
- }
66
- function paintThemeTabs() {
67
- var v = currentTheme();
68
- Array.prototype.forEach.call(document.querySelectorAll('[data-theme-val]'), function(b) {
69
- b.classList.toggle('on', b.getAttribute('data-theme-val') === v);
70
- });
71
- }
72
- function applyTheme(v) {
73
- if (v === 'system') document.documentElement.removeAttribute('data-theme');
74
- else document.documentElement.setAttribute('data-theme', v);
75
- try { localStorage.setItem('tersio-theme', v); } catch (e) { }
76
- paintThemeTabs();
77
- }
78
- applyTheme(currentTheme());
79
- (function initRail() {
80
- var rail = document.getElementById('rail');
81
- if (!rail || initRail.done) return;
82
- initRail.done = true;
83
- var N = 24, ticks = [];
84
- for (var i = 0; i < N; i++) {
85
- (function(i) {
86
- var b = document.createElement('button');
87
- b.type = 'button'; b.tabIndex = -1; b.setAttribute('aria-hidden', 'true');
88
- b.addEventListener('click', function() {
89
- var max = document.documentElement.scrollHeight - window.innerHeight;
90
- window.scrollTo({ top: max * (i / (N - 1)), behavior: 'smooth' });
91
- });
92
- rail.appendChild(b); ticks.push(b);
93
- })(i);
94
- }
95
- var queued = false;
96
- function paint() {
97
- queued = false;
98
- var max = document.documentElement.scrollHeight - window.innerHeight;
99
- var f = max > 0 ? window.scrollY / max : 0;
100
- ticks.forEach(function(b, j) { b.classList.toggle('lit', j / (N - 1) <= f); });
101
- }
102
- window.addEventListener('scroll', function() {
103
- if (!queued) { queued = true; requestAnimationFrame(paint); }
104
- }, { passive: true });
105
- paint();
106
- })();
107
- (function initFx() {
108
- var btn = document.getElementById('fxBtn'), panel = document.getElementById('fxPanel'), cur = document.getElementById('fxCur');
109
- var keys = Object.keys(CURS), active = -1;
110
- function paint() {
111
- cur.textContent = FLAGS[fx.cur] + ' ' + fx.cur;
112
- Array.prototype.forEach.call(panel.children, function(o) {
113
- o.setAttribute('aria-selected', o.dataset.cur === fx.cur ? 'true' : 'false');
114
- });
115
- }
116
- function setCur(k) {
117
- fx.cur = k; active = keys.indexOf(k);
118
- try { localStorage.setItem('tersio-fx-cur', fx.cur); } catch (e) { }
119
- // Served mode: also persist server-side so close → reopen keeps the
120
- // choice. Every `tersio gain` run is a fresh ephemeral port (a new
121
- // origin), so localStorage alone cannot survive a restart.
122
- if (window.location.protocol !== 'file:' && typeof fetch === 'function') {
123
- try {
124
- fetch('currency', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: k }) }).catch(function() { });
125
- } catch (e) { }
126
- }
127
- paint();
128
- if (DATA) render(DATA);
129
- }
130
- function open(show) {
131
- var willOpen = show === undefined ? panel.classList.contains('hidden') : show;
132
- panel.classList.toggle('hidden', !willOpen);
133
- btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
134
- if (willOpen) mark(keys.indexOf(fx.cur));
135
- }
136
- function mark(i) {
137
- active = (i + keys.length) % keys.length;
138
- Array.prototype.forEach.call(panel.children, function(o, j) {
139
- o.classList.toggle('active', j === active);
140
- });
141
- var el = panel.children[active];
142
- if (el) el.focus();
143
- }
144
- keys.forEach(function(k) {
145
- var o = document.createElement('button');
146
- o.type = 'button'; o.className = 'fxopt mono'; o.dataset.cur = k;
147
- o.setAttribute('role', 'option');
148
- o.innerHTML = '<span>' + FLAGS[k] + ' ' + k + '</span><svg class="tick" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
149
- o.addEventListener('click', function() { setCur(k); open(false); btn.focus(); });
150
- o.addEventListener('mousemove', function() { mark(keys.indexOf(k)); });
151
- panel.appendChild(o);
152
- });
153
- paint();
154
- btn.addEventListener('click', function() { open(); });
155
- btn.addEventListener('keydown', function(ev) {
156
- if (ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); open(true); }
157
- });
158
- panel.addEventListener('keydown', function(ev) {
159
- if (ev.key === 'Escape') { open(false); btn.focus(); }
160
- else if (ev.key === 'ArrowDown') { ev.preventDefault(); mark(active + 1); }
161
- else if (ev.key === 'ArrowUp') { ev.preventDefault(); mark(active - 1); }
162
- else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setCur(keys[active]); open(false); btn.focus(); }
163
- else if (ev.key === 'Tab') open(false);
164
- });
165
- document.addEventListener('click', function(ev) {
166
- if (!panel.classList.contains('hidden') && !btn.contains(ev.target) && !panel.contains(ev.target)) open(false);
167
- });
168
- if (typeof fetch !== 'function' || typeof AbortController === 'undefined') return;
169
- var ctl = new AbortController();
170
- var to = setTimeout(function() { ctl.abort(); }, 4000);
171
- fetch('https://api.frankfurter.app/latest?from=USD', { signal: ctl.signal }).then(function(r) { return r.json(); }).then(function(j) {
172
- clearTimeout(to);
173
- if (!j || !j.rates) return;
174
- var ok = Object.keys(CURS).every(function(k) { return k === 'USD' || typeof j.rates[k] === 'number'; });
175
- if (!ok) return;
176
- fx.rates = Object.assign({ USD: 1 }, j.rates); fx.live = true;
177
- try { localStorage.setItem('tersio-fx', JSON.stringify({ rates: fx.rates, ts: Date.now() })); } catch (e) { }
178
- if (DATA) render(DATA);
179
- }).catch(function() { clearTimeout(to); });
180
- })();
181
- Array.prototype.forEach.call(document.querySelectorAll('[data-theme-val]'), function(b) {
182
- b.addEventListener('click', function() { applyTheme(b.getAttribute('data-theme-val')); });
183
- });
184
- (function() {
185
- var sysMedia = window.matchMedia('(prefers-color-scheme: dark)');
186
- if (sysMedia && typeof sysMedia.addEventListener === 'function') sysMedia.addEventListener('change', function() {
187
- if (currentTheme() === 'system') applyTheme('system');
188
- });
189
- })();
190
-
2
+ var T = window.Tersio;
3
+ var fmt = T.fmt, fmtShort = T.fmtShort, CURS = T.CURS, FLAGS = T.FLAGS, fx = T.fx,
4
+ fxMoney = T.fxMoney, esc = T.esc, countUp = T.countUp,
5
+ showTip = T.showTip, moveTip = T.moveTip, hideTip = T.hideTip,
6
+ reduce = T.reduce, hasGsap = T.hasGsap, hasST = T.hasST;
191
7
  var OPENAI_SVG = '<svg viewBox="0 0 24 24" fill="#000" aria-hidden="true"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>';
192
8
  var PROVIDERS = [
193
9
  [/openai|codex|gpt-|o1/i, 'OpenAI', 'openai', '#fff', OPENAI_SVG],
@@ -258,7 +74,7 @@
258
74
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
259
75
  return b.input + b.output + b.cacheRead + b.cacheWrite;
260
76
  }
261
- var DATA = null, cardsShown = false;
77
+ var cardsShown = false;
262
78
  function topModels(byModel, n) {
263
79
  return Object.keys(byModel).sort(function(a, b) { return modelTotal(byModel, b) - modelTotal(byModel, a); }).slice(0, n);
264
80
  }
@@ -283,36 +99,11 @@
283
99
  }
284
100
  function allDays() {
285
101
  var set = {};
286
- Object.keys(DATA.byDay || {}).forEach(function(d) { set[d] = 1; });
287
- Object.keys(DATA.byDayModel || {}).forEach(function(d) { set[d] = 1; });
102
+ Object.keys(T.getData().byDay || {}).forEach(function(d) { set[d] = 1; });
103
+ Object.keys(T.getData().byDayModel || {}).forEach(function(d) { set[d] = 1; });
288
104
  return Object.keys(set).sort();
289
105
  }
290
106
 
291
- var tipEl = null;
292
- function tip() {
293
- if (!tipEl) {
294
- tipEl = document.createElement('div');
295
- tipEl.id = 'tip'; tipEl.className = 'mono'; tipEl.setAttribute('role', 'tooltip');
296
- document.body.appendChild(tipEl);
297
- }
298
- return tipEl;
299
- }
300
- function showTip(html, x, y) {
301
- var t = tip();
302
- t.innerHTML = html;
303
- if (window.lucide) lucide.createIcons();
304
- t.classList.add('show');
305
- moveTip(x, y);
306
- }
307
- function moveTip(x, y) {
308
- if (!tipEl) return;
309
- var w = 300, h = tipEl.offsetHeight || 220;
310
- var lx = x + 16 > window.innerWidth - w ? x - w - 12 : x + 16;
311
- var ly = y + 16 > window.innerHeight - h ? y - h - 12 : y + 16;
312
- tipEl.style.left = Math.max(8, lx) + 'px';
313
- tipEl.style.top = Math.max(8, ly) + 'px';
314
- }
315
- function hideTip() { if (tipEl) tipEl.classList.remove('show'); }
316
107
  function dateHead(key) {
317
108
  var dt = new Date(key + 'T12:00:00');
318
109
  return isNaN(dt) ? key : dt.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase();
@@ -327,12 +118,12 @@
327
118
  return h;
328
119
  }
329
120
  function tipModelHTML(m) {
330
- var b = (DATA.byModel || {})[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
331
- var req = ((DATA.byModelMessages || {})[m]) || 0;
121
+ var b = (T.getData().byModel || {})[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
122
+ var req = ((T.getData().byModelMessages || {})[m]) || 0;
332
123
  var denom = b.input + b.cacheRead;
333
124
  var hit = denom ? (b.cacheRead / denom * 100).toFixed(1) + '%' : '-';
334
125
  var total = b.input + b.output + b.cacheRead + b.cacheWrite;
335
- var cb = ((DATA.byModelBucketUsd || {})[m]) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
126
+ var cb = ((T.getData().byModelBucketUsd || {})[m]) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
336
127
  var h = '<div class="tt">' + shortName(m) + '</div><div class="tv">' + fmtShort(total) + ' total (' + fxMoney(cb.input + cb.output + cb.cacheRead + cb.cacheWrite) + ')</div>';
337
128
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">input</span><span class="tvr">' + fmt(b.input) + ' (' + fxMoney(cb.input) + ')</span></div>';
338
129
  h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">output</span><span class="tvr">' + fmt(b.output) + ' (' + fxMoney(cb.output) + ')</span></div>';
@@ -405,17 +196,17 @@
405
196
  return ['meh', 'low', 'warn'];
406
197
  }
407
198
  function tipCo2HTML() {
408
- var z = co2Zone(DATA.co2g || 0);
409
- var h = '<div class="tt">CO2</div><div class="tv">~' + (DATA.co2g || 0).toFixed(1) + 'g est.</div>';
199
+ var z = co2Zone(T.getData().co2g || 0);
200
+ var h = '<div class="tt">CO2</div><div class="tv">~' + (T.getData().co2g || 0).toFixed(1) + 'g est.</div>';
410
201
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
411
- h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">energy</span><span class="tvr">~' + (DATA.energyWh || 0).toFixed(1) + ' Wh</span></div>';
202
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">energy</span><span class="tvr">~' + (T.getData().energyWh || 0).toFixed(1) + ' Wh</span></div>';
412
203
  return h;
413
204
  }
414
205
  function tipSavedHTML() {
415
- var usd = DATA.usd || 0, saved = DATA.savedUsd || 0;
206
+ var usd = T.getData().usd || 0, saved = T.getData().savedUsd || 0;
416
207
  var lev = usd ? saved / usd : 0;
417
208
  var z = levZone(lev);
418
- var t = DATA.tokens || { cacheRead: 0 };
209
+ var t = T.getData().tokens || { cacheRead: 0 };
419
210
  var h = '<div class="tt">Saved by cache</div><div class="tv">' + fxMoney(saved) + ' est.</div>';
420
211
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
421
212
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">leverage</span><span class="tvr">x' + lev.toFixed(1) + ' per $1</span></div>';
@@ -424,7 +215,7 @@
424
215
  }
425
216
  function tipShareHTML(share, pp) {
426
217
  var z = shareZone(share);
427
- var t = DATA.tokens || { cacheRead: 0, cacheWrite: 0 };
218
+ var t = T.getData().tokens || { cacheRead: 0, cacheWrite: 0 };
428
219
  var h = '<div class="tt">Cache share</div><div class="tv">' + share + '%</div>';
429
220
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
430
221
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">read</span><span class="tvr">' + fmtShort(t.cacheRead || 0) + '</span></div>';
@@ -450,7 +241,7 @@
450
241
  var mdModel = null, mdSpan = 'all', mdHover = { pts: [], days: [] };
451
242
  function mdSeries(m) {
452
243
  var days = allDays();
453
- var per = DATA.byDayModel || {};
244
+ var per = T.getData().byDayModel || {};
454
245
  return days.map(function(d) { return { day: d, v: ((per[d] || {})[m] || 0) }; });
455
246
  }
456
247
  function mdSlice(series) {
@@ -473,18 +264,19 @@
473
264
  var step = pts.length > 1 ? (W - 16) / (pts.length - 1) : 0;
474
265
  function X(i) { return 8 + i * step; }
475
266
  function Y(p) { return 14 + (1 - p / max) * (H - 36); }
267
+ // shadcn CartesianGrid language: subtle dashed horizontals only.
476
268
  var grid = '';
477
- for (i = 1; i <= 3; i++) { y = 14 + (H - 36) * i / 3; grid += '<line class="grid" x1="8" y1="' + y.toFixed(1) + '" x2="' + (W - 8) + '" y2="' + y.toFixed(1) + '"/>'; }
269
+ for (i = 1; i <= 3; i++) { y = 14 + (H - 36) * i / 3; grid += '<line class="grid" x1="8" y1="' + y.toFixed(1) + '" x2="' + (W - 8) + '" y2="' + y.toFixed(1) + '" stroke-dasharray="3 4"/>'; }
478
270
  var d = pts.map(function(p, i) { return (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(p).toFixed(1); }).join(' ');
479
271
  var last = pts.length ? { x: X(pts.length - 1), y: Y(pts[pts.length - 1]) } : { x: 8, y: H - 22 };
480
272
  svg.innerHTML = '<defs><linearGradient id="mdGrad" x1="0" y1="0" x2="0" y2="1">' +
481
- '<stop offset="0" style="stop-color:var(--accent)" stop-opacity="0.35"/>' +
482
- '<stop offset="1" style="stop-color:var(--accent)" stop-opacity="0"/></linearGradient></defs>' +
273
+ '<stop offset="0" style="stop-color:var(--accent)" stop-opacity="0.32"/>' +
274
+ '<stop offset="1" style="stop-color:var(--accent)" stop-opacity="0.02"/></linearGradient></defs>' +
483
275
  grid +
484
276
  (pts.length ? '<path class="area" d="' + d + ' L' + last.x.toFixed(1) + ' ' + (H - 22) + ' L8 ' + (H - 22) + ' Z"/>' : '') +
485
- (pts.length ? '<path class="line" d="' + d + '"/>' : '') +
486
- (pts.length ? '<circle class="dot" cx="' + last.x.toFixed(1) + '" cy="' + last.y.toFixed(1) + '" r="4"/>' : '') +
487
- (pts.length ? '<circle class="hoverdot" id="mdHoverDot" cx="-10" cy="-10" r="4" style="display:none"/>' : '');
277
+ (pts.length ? '<path class="line" d="' + d + '" stroke-width="2.5"/>' : '') +
278
+ (pts.length ? '<circle class="dot" cx="' + last.x.toFixed(1) + '" cy="' + last.y.toFixed(1) + '" r="5"/>' : '') +
279
+ (pts.length ? '<circle class="hoverdot" id="mdHoverDot" cx="-10" cy="-10" r="5" style="display:none"/>' : '');
488
280
  mdHover = { pts: pts, days: days };
489
281
  svg.onmousemove = function(ev) {
490
282
  var tip = document.getElementById('mdTip');
@@ -494,7 +286,7 @@
494
286
  var idx = Math.max(0, Math.min(mdHover.pts.length - 1, Math.round((fx - 8) / (step || 1))));
495
287
  var dot = document.getElementById('mdHoverDot');
496
288
  if (dot) { dot.setAttribute('cx', X(idx).toFixed(1)); dot.setAttribute('cy', Y(mdHover.pts[idx]).toFixed(1)); dot.style.display = ''; }
497
- tip.textContent = mdShortDay(mdHover.days[idx]) + ' · ' + fmtShort(mdHover.pts[idx]);
289
+ tip.innerHTML = '<div class="tt">' + mdShortDay(mdHover.days[idx]) + '</div><div class="tv">' + fmtShort(mdHover.pts[idx]) + ' tokens</div>';
498
290
  tip.style.display = 'block';
499
291
  // Raw % positioning overflows past the dialog edge on the first/last
500
292
  // points (the dialog clips it). Measure and pin inside the card.
@@ -529,6 +321,13 @@
529
321
  else if (avg === max) s.className = 'top';
530
322
  s.style.height = Math.max(avg ? 5 : 2, Math.round(avg / max * 100)) + '%';
531
323
  s.title = mdShortDay(days[i]) + ' · ' + fmtShort(Math.round(sum));
324
+ (function(day, tokens) {
325
+ s.addEventListener('mouseenter', function(ev) {
326
+ showTip('<div class="tt">' + day + '</div><div class="tv">' + tokens + ' tokens</div>', ev.clientX, ev.clientY);
327
+ });
328
+ s.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
329
+ s.addEventListener('mouseleave', hideTip);
330
+ })(mdShortDay(days[i]), fmtShort(Math.round(sum)));
532
331
  el.appendChild(s);
533
332
  }
534
333
  // Sparse models leave the bars mostly flat: name the peak day, its share
@@ -542,18 +341,31 @@
542
341
  function mdDonut(svg, parts) {
543
342
  var r = 54, C = 2 * Math.PI * r, off = 0;
544
343
  var total = parts.reduce(function(a, p) { return a + p.v; }, 0) || 1;
344
+ var live = parts.filter(function(p) { return p.v > 0; });
345
+ // shadcn pie paddingAngle: breathing room between segments.
346
+ var gap = live.length > 1 ? 4 : 0;
545
347
  var html = '<circle class="tk" cx="70" cy="70" r="' + r + '"/>';
546
348
  parts.forEach(function(p) {
547
349
  var len = p.v / total * C;
548
350
  if (len <= 0) return;
549
- html += '<circle cx="70" cy="70" r="' + r + '" style="stroke:' + p.color + '" stroke-dasharray="' + len.toFixed(1) + ' ' + (C - len).toFixed(1) + '" stroke-dashoffset="' + (-off).toFixed(1) + '" stroke-linecap="butt"/>';
351
+ html += '<circle cx="70" cy="70" r="' + r + '" style="stroke:' + p.color + '" stroke-dasharray="' + Math.max(len - gap, 0.5).toFixed(1) + ' ' + (C - len + gap).toFixed(1) + '" stroke-dashoffset="' + (-off - gap / 2).toFixed(1) + '" stroke-linecap="butt"/>';
550
352
  off += len;
551
353
  });
552
354
  svg.innerHTML = html;
553
355
  document.getElementById('mdMixTotal').textContent = fmtShort(total);
554
- document.getElementById('mdMixLegend').innerHTML = parts.filter(function(p) { return p.v > 0; }).map(function(p) {
555
- return '<div class="row"><span class="dot" style="background:' + p.color + '"></span><span>' + p.label + '</span><span class="pct">' + (p.v / total * 100).toFixed(1) + '% · ' + fmtShort(p.v) + '</span></div>';
356
+ var legend = document.getElementById('mdMixLegend');
357
+ legend.innerHTML = live.map(function(p) {
358
+ return '<div class="row" data-mix="' + p.label + '"><span class="dot" style="background:' + p.color + '"></span><span>' + p.label + '</span><span class="pct">' + (p.v / total * 100).toFixed(1) + '% · ' + fmtShort(p.v) + '</span></div>';
556
359
  }).join('');
360
+ Array.prototype.forEach.call(legend.children, function(rowEl) {
361
+ var part = live.filter(function(p) { return p.label === rowEl.getAttribute('data-mix'); })[0];
362
+ if (!part) return;
363
+ rowEl.addEventListener('mouseenter', function(ev) {
364
+ showTip('<div class="tt">' + part.label + '</div><div class="tv">' + fmtShort(part.v) + ' (' + (part.v / total * 100).toFixed(1) + '%)</div>', ev.clientX, ev.clientY);
365
+ });
366
+ rowEl.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
367
+ rowEl.addEventListener('mouseleave', hideTip);
368
+ });
557
369
  }
558
370
  function mdMonths(el, days) {
559
371
  el.innerHTML = '';
@@ -585,7 +397,7 @@
585
397
  return { t: (pct >= 0 ? '+' : '') + pct.toFixed(0) + '% vs prior 2 wks', up: pct >= 0 };
586
398
  }
587
399
  function renderModelDialog(m) {
588
- var byModel = DATA.byModel || {};
400
+ var byModel = T.getData().byModel || {};
589
401
  var tops = topModels(byModel, Object.keys(byModel).length);
590
402
  var rank = tops.indexOf(m) + 1;
591
403
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -593,8 +405,8 @@
593
405
  var grand = 1;
594
406
  tops.forEach(function(k) { grand += modelTotal(byModel, k); });
595
407
  var v = vendorOf(m);
596
- var req = ((DATA.byModelMessages || {})[m]) || 0;
597
- var usd = ((DATA.byModelUsd || {})[m]) || 0;
408
+ var req = ((T.getData().byModelMessages || {})[m]) || 0;
409
+ var usd = ((T.getData().byModelUsd || {})[m]) || 0;
598
410
  var denom = b.input + b.cacheRead;
599
411
  var hit = denom ? (b.cacheRead / denom * 100) : 0;
600
412
  var series = mdSlice(mdSeries(m));
@@ -659,8 +471,8 @@
659
471
  if (!dlg.open) dlg.showModal();
660
472
  }
661
473
  function dayModelRows(key) {
662
- var per = (DATA.byDayModel || {})[key] || {};
663
- var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
474
+ var per = (T.getData().byDayModel || {})[key] || {};
475
+ var tops = topModels(T.getData().byModel || {}, 8), rows = [], other = 0;
664
476
  tops.forEach(function(m, i) {
665
477
  var v = per[m] || 0;
666
478
  if (v) rows.push([m, v, PALETTE[i % PALETTE.length]]);
@@ -682,7 +494,7 @@
682
494
  sun.setDate(mon.getDate() + 6);
683
495
  var head = isNaN(mon) ? monKey : mon.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase() +
684
496
  ' - ' + (isNaN(sun) ? '' : sun.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase());
685
- var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
497
+ var tops = topModels(T.getData().byModel || {}, 8), rows = [], other = 0;
686
498
  tops.forEach(function(m, i) {
687
499
  var v = weekPer[m] || 0;
688
500
  if (v) rows.push([m, v, PALETTE[i % PALETTE.length]]);
@@ -729,7 +541,7 @@
729
541
  if (GMODE === 'weekly' && !isNaN(dt)) {
730
542
  var mk = dayKey(mondayOf(dt));
731
543
  weekTotal[mk] = (weekTotal[mk] || 0) + (sums[k] || 0);
732
- var per = (DATA.byDayModel || {})[k] || {};
544
+ var per = (T.getData().byDayModel || {})[k] || {};
733
545
  if (!weekPer[mk]) weekPer[mk] = {};
734
546
  Object.keys(per).forEach(function(m) { weekPer[mk][m] = (weekPer[mk][m] || 0) + per[m]; });
735
547
  }
@@ -812,7 +624,7 @@
812
624
  Array.prototype.forEach.call(document.querySelectorAll('#activity [data-gmode]'), function(b) { b.classList.remove('on'); });
813
625
  btn.classList.add('on');
814
626
  GMODE = btn.getAttribute('data-gmode');
815
- if (DATA) renderGraph(DATA.byDay || {});
627
+ if (T.getData()) renderGraph(T.getData().byDay || {});
816
628
  });
817
629
  });
818
630
  }
@@ -847,7 +659,7 @@
847
659
  });
848
660
  }
849
661
  function renderModels() {
850
- var byModel = DATA.byModel || {};
662
+ var byModel = T.getData().byModel || {};
851
663
  var tops = topModels(byModel, Object.keys(byModel).length).filter(function(m) { return modelTotal(byModel, m) > 0; });
852
664
  var cards = document.getElementById('modelCards');
853
665
  cards.innerHTML = '';
@@ -920,7 +732,7 @@
920
732
  var right = document.createElement('div'); right.className = 'shrink-0 text-right';
921
733
  var n = document.createElement('p'); n.className = 'mono font-bold'; n.textContent = fmt(modelTotal(byModel, m));
922
734
  var cost = document.createElement('p'); cost.className = 'mono text-xs'; cost.style.color = 'var(--dim)';
923
- cost.textContent = fxMoney((DATA.byModelUsd || {})[m] || 0);
735
+ cost.textContent = fxMoney((T.getData().byModelUsd || {})[m] || 0);
924
736
  right.appendChild(n); right.appendChild(cost);
925
737
  li.appendChild(badge); li.appendChild(mid); li.appendChild(right);
926
738
  hoverModel(li, m);
@@ -978,7 +790,7 @@
978
790
  });
979
791
  }
980
792
  function renderCmd() {
981
- var g = (DATA.rtkGain || {});
793
+ var g = (T.getData().rtkGain || {});
982
794
  var byName = {};
983
795
  function add(name, count, saved, avgPct, avgMs) {
984
796
  var r = byName[name];
@@ -986,7 +798,7 @@
986
798
  r.count += count;
987
799
  if (saved !== null && saved !== undefined) r.saved = (r.saved || 0) + saved;
988
800
  }
989
- (DATA.byTool || []).forEach(function(r) { add(r[0], r[1], null, null, null); });
801
+ (T.getData().byTool || []).forEach(function(r) { add(r[0], r[1], null, null, null); });
990
802
  (g.byCommand || []).forEach(function(r) { add(r.command, r.count, r.saved, r.avgPct, r.avgMs); });
991
803
  cmdRows = Object.keys(byName).map(function(k) { return byName[k]; });
992
804
  sortCmd();
@@ -1198,12 +1010,11 @@
1198
1010
 
1199
1011
  function dayTotal(b) { return (b.input || 0) + (b.output || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0); }
1200
1012
 
1201
- function render(d) {
1202
- DATA = d;
1013
+ function renderMain(d) {
1203
1014
  // Server-provided default currency (tersio gain --currency); a saved
1204
1015
  // picker choice in localStorage always wins.
1205
- if (!render.fxInit) {
1206
- render.fxInit = true;
1016
+ if (!renderMain.fxInit) {
1017
+ renderMain.fxInit = true;
1207
1018
  var saved = null;
1208
1019
  try { saved = localStorage.getItem('tersio-fx-cur'); } catch (e) { }
1209
1020
  if (!saved && d.currency && CURS[d.currency]) {
@@ -1296,9 +1107,9 @@
1296
1107
  (function() {
1297
1108
  var el = document.getElementById('ticker');
1298
1109
  if (!el) return;
1299
- var tops = topModels(DATA.byModel || {}, 5);
1110
+ var tops = topModels(T.getData().byModel || {}, 5);
1300
1111
  var parts = tops.map(function(m) {
1301
- var b = (DATA.byModel || {})[m];
1112
+ var b = (T.getData().byModel || {})[m];
1302
1113
  var v = b.input + b.output + b.cacheRead + b.cacheWrite;
1303
1114
  var nm = m.toUpperCase().replace(/-(FREE|CONTRIBUTOR.*|NEXT|LATEST)$/, '').replace(/[-.]?\d[\d.]*/, '').replace(/-V(?=-|$)/, '');
1304
1115
  return nm + ' ' + fmtShort(v);
@@ -1317,11 +1128,11 @@
1317
1128
  renderCmd();
1318
1129
 
1319
1130
  if (window.lucide) lucide.createIcons();
1320
- if (hasGsap && !render.introDone) {
1321
- render.introDone = true;
1131
+ if (hasGsap && !renderMain.introDone) {
1132
+ renderMain.introDone = true;
1322
1133
  gsap.from('.hero-in', { y: 26, opacity: 0, duration: 0.8, ease: 'power3.out', stagger: 0.08 });
1323
1134
  }
1324
- observe();
1135
+ T.observe();
1325
1136
  }
1326
1137
  // shadcn/ui badge: default/secondary/destructive/outline carry the meaning,
1327
1138
  // the label carries the detail. Built as DOM nodes so provider error notes
@@ -1347,7 +1158,7 @@
1347
1158
  function renderRecent() {
1348
1159
  var body = document.getElementById('recent');
1349
1160
  body.innerHTML = '';
1350
- var all = DATA.recent || [];
1161
+ var all = T.getData().recent || [];
1351
1162
  recentRows = all.slice();
1352
1163
  sortRecent();
1353
1164
  var recentPages = Math.max(1, Math.ceil(recentRows.length / recentPer));
@@ -1390,106 +1201,8 @@
1390
1201
  document.getElementById('recentTable').style.display = all.length ? '' : 'none';
1391
1202
  document.getElementById('emptyRecent').classList.toggle('hidden', all.length > 0);
1392
1203
  }
1393
-
1394
- var seen = new WeakSet();
1395
- function observe() {
1396
- var els = document.querySelectorAll('.rise');
1397
- if (!('IntersectionObserver' in window) || reduce) {
1398
- els.forEach(function(el) { el.classList.add('in'); });
1399
- return;
1400
- }
1401
- var io = new IntersectionObserver(function(entries) {
1402
- entries.forEach(function(e) {
1403
- if (e.isIntersecting && !seen.has(e.target)) {
1404
- seen.add(e.target); e.target.classList.add('in'); io.unobserve(e.target);
1405
- }
1406
- });
1407
- }, { threshold: 0.12 });
1408
- els.forEach(function(el) { if (!seen.has(el)) io.observe(el); });
1409
- }
1410
-
1411
- var lastJson = '';
1412
- function load() {
1413
- // Served mode only: file:// exports have no data.json endpoint, so
1414
- // polling there would just burn cycles on 404s.
1415
- if (window.location.protocol === 'file:') return;
1416
- // Skip background tabs: no render work while hidden, instant refresh on return.
1417
- if (document.hidden) return;
1418
- fetch('data.json').then(function(r) { return r.json(); }).then(function(d) {
1419
- var json = JSON.stringify(d);
1420
- if (json === lastJson) return;
1421
- lastJson = json;
1422
- render(d);
1423
- }).catch(function() {
1424
- var cards = document.getElementById('modelCards');
1425
- if (cards && !cards.children.length) cards.innerHTML = '<div class="empty md:col-span-3">' + emptyState('cloud-off', 'Could not load data', 'Serve with tersio gain instead of opening this file directly.') + '</div>';
1426
- if (window.lucide) lucide.createIcons();
1427
- observe();
1428
- });
1429
- }
1430
- // Live view: re-read data.json every 5s while served and visible; identical
1431
- // payloads skip render. The manual Reload button stays as an instant refresh.
1432
- setInterval(load, 5000);
1433
- document.addEventListener('visibilitychange', function() { if (!document.hidden) load(); });
1434
- function toast(title, desc, icon) {
1435
- var wrap = document.querySelector('.toaster');
1436
- if (!wrap) {
1437
- wrap = document.createElement('div');
1438
- wrap.className = 'toaster';
1439
- wrap.popover = 'manual';
1440
- document.body.appendChild(wrap);
1441
- if (wrap.showPopover) wrap.showPopover(); // top layer: paints above the native settings dialog
1442
- }
1443
- var el = document.createElement('div');
1444
- el.className = 'toast';
1445
- el.innerHTML = '<span class="toast-icon"><i data-lucide="' + (icon || 'check') + '" class="size-4"></i></span><div><p class="toast-title"></p><p class="toast-desc"></p></div>';
1446
- el.querySelector('.toast-title').textContent = title;
1447
- el.querySelector('.toast-desc').textContent = desc;
1448
- wrap.appendChild(el);
1449
- if (window.lucide) lucide.createIcons();
1450
- setTimeout(function() {
1451
- el.classList.add('out');
1452
- setTimeout(function() { el.remove(); }, 200);
1453
- }, 4000);
1454
- }
1455
- document.getElementById('reload').addEventListener('click', load);
1456
- (function() {
1457
- var dlg = document.getElementById('settings');
1458
- if (dlg && typeof dlg.showModal === 'function') {
1459
- document.getElementById('settingsBtn').addEventListener('click', function() { dlg.showModal(); });
1460
- document.getElementById('settingsClose').addEventListener('click', function() { dlg.close(); });
1461
- dlg.addEventListener('click', function(ev) { if (ev.target === dlg) dlg.close(); });
1462
- }
1463
- var md = document.getElementById('modelDialog');
1464
- if (md && typeof md.showModal === 'function') {
1465
- document.getElementById('mdClose').addEventListener('click', function() { md.close(); });
1466
- md.addEventListener('click', function(ev) { if (ev.target === md) md.close(); });
1467
- }
1468
- })();
1469
- (function() {
1470
- var resetBtn = document.getElementById('reset');
1471
- if (!resetBtn) return;
1472
- if (window.location.protocol === 'file:') { resetBtn.style.display = 'none'; return; }
1473
- var label = document.getElementById('resetLabel');
1474
- resetBtn.addEventListener('click', function() {
1475
- if (resetBtn.dataset.armed) {
1476
- delete resetBtn.dataset.armed;
1477
- label.textContent = 'Clearing…';
1478
- fetch('reset', { method: 'POST' }).then(function(r) { return r.json(); }).then(function() {
1479
- label.textContent = 'Reset';
1480
- load();
1481
- toast('Statistics reset', 'The usage statistics view now starts fresh. Transcripts and RTK history were never touched.', 'rotate-ccw');
1482
- }).catch(function() {
1483
- label.textContent = 'Reset';
1484
- toast('Reset failed', 'Could not reach the server. Try again.', 'circle-alert');
1485
- });
1486
- } else {
1487
- resetBtn.dataset.armed = '1';
1488
- label.textContent = 'Sure?';
1489
- setTimeout(function() { delete resetBtn.dataset.armed; if (label.textContent === 'Sure?') label.textContent = 'Reset'; }, 3000);
1490
- }
1491
- });
1492
- })();
1493
- load();
1494
- observe();
1204
+ T.renderMain = renderMain;
1205
+ T.openModelDialog = openModelDialog;
1206
+ T.dayTotal = dayTotal;
1207
+ T.emptyState = emptyState;
1495
1208
  })();