oxedyne/daimond/www/js/spend.js
22.1 KiB, 1 run
created by r2519314175:1443, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* spend.js — the Spending view: where the money goes. |
| 2 | * |
| 3 | * Daimond spends from two separate pots, and this shows both without pretending |
| 4 | * they are one: |
| 5 | * |
| 6 | * Inference — the model calls themselves, billed to the user's OWN provider |
| 7 | * key. The browser talks to the provider directly, so the gateway never |
| 8 | * sees this; the cost is priced and logged per turn in `DaimondLedger` |
| 9 | * (localStorage), and read back here. This is almost always the largest |
| 10 | * line, so it comes first. |
| 11 | * |
| 12 | * Credits — a prepaid balance held on the gateway, spent only on the few |
| 13 | * things that must leave the browser: fetching a web page, syncing or |
| 14 | * sending mail, and (soon) cross-device sync. Read from `/api/ledger`, |
| 15 | * already tagged with a category so the breakdown never parses a string. |
| 16 | * |
| 17 | * The two are different currencies and different accounts, so they are shown as |
| 18 | * two tracks, never summed. Everything is drawn in plain SVG and DOM — no chart |
| 19 | * library, no dependency. |
| 20 | * |
| 21 | * Exposes `window.DaimondSpend = { onOpen, refresh, show }`. `daimond.js` calls |
| 22 | * `onOpen()` when the Spending panel is revealed; the header spend meter's click |
| 23 | * calls `DaimondPanels.show('spend')`, which routes through it. |
| 24 | */ |
| 25 | (function () { |
| 26 | 'use strict'; |
| 27 | |
| 28 | var period = 'month'; // inference window: 'week' | 'month' |
| 29 | var creditEntries = []; // last read of /api/ledger |
| 30 | var wiredActions = false; |
| 31 | |
| 32 | // ── Small DOM + format helpers ───────────────────────────── |
| 33 | |
| 34 | function el(tag, cls, text) { |
| 35 | var e = document.createElement(tag); |
| 36 | if (cls) e.className = cls; |
| 37 | if (text != null) e.textContent = text; |
| 38 | return e; |
| 39 | } |
| 40 | |
| 41 | // Inference costs are provider USD, often sub-cent, so show enough figures |
| 42 | // to be honest about a small number without a wall of zeros on a large one. |
| 43 | // The `fine` cascade in i18n.js is this rule; the display currency rides |
| 44 | // along with it. |
| 45 | function fmtUsd(v) { |
| 46 | return DaimondI18n.money(v, 'fine'); |
| 47 | } |
| 48 | |
| 49 | // Credits are gateway minor units; reuse the gateway's own formatter. |
| 50 | function fmtCredits(minor) { |
| 51 | var g = window.DaimondGateway; |
| 52 | var cur = (g && g.state && g.state().currency) || 'usd'; |
| 53 | return DaimondI18n.moneyMinor(minor, cur); |
| 54 | } |
| 55 | |
| 56 | function fmtTokens(n) { |
| 57 | n = n || 0; |
| 58 | if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; |
| 59 | if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; |
| 60 | return String(n); |
| 61 | } |
| 62 | |
| 63 | // A short, local, human day/time for a ledger row. |
| 64 | function fmtWhen(tsMs) { |
| 65 | try { |
| 66 | var d = new Date(tsMs); |
| 67 | return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) |
| 68 | + ' ' + d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); |
| 69 | } catch (e) { return ''; } |
| 70 | } |
| 71 | |
| 72 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 73 | |
| 74 | // Every category a movement can carry. The gateway is the authority on this |
| 75 | // list: the first seven are `SPEND_CATEGORIES` in gateway/src/schema.rs, the |
| 76 | // rest are the credit-side kinds `LedgerEntry::category` names from the kind |
| 77 | // alone. |
| 78 | // |
| 79 | // This is a copy, and a copy is what went wrong: `infer` became a category on |
| 80 | // 2026-07-17 and `storage` on 2026-07-21, this list was told about neither, |
| 81 | // and for three weeks a user's own breakdown read "infer" and "storage" at |
| 82 | // them in eight languages. dev/verify_spendcats.mjs reads the Rust and fails |
| 83 | // when this list cannot name something the gateway can produce, so the next |
| 84 | // category is a red check rather than three more weeks. |
| 85 | var CATS = [ |
| 86 | 'web', 'search', 'mail', 'sync', 'storage', 'infer', 'other', // metered spends |
| 87 | 'topup', 'refund', 'grant', 'adjust', // credit-side movements |
| 88 | ]; |
| 89 | |
| 90 | // Categories already reported, so the console carries one line per unknown |
| 91 | // name rather than one per row per redraw. |
| 92 | var unnamed = {}; |
| 93 | |
| 94 | // A friendly label for a credit category / kind. |
| 95 | // |
| 96 | // A category this build cannot name is money that left the balance with |
| 97 | // nothing to say for it, and the row says exactly that. Printing the |
| 98 | // gateway's own token instead is what let the last drift ship: "infer" looks |
| 99 | // like a label to nobody and like a bug to no one either, so nothing was |
| 100 | // reported and nothing was fixed. The token goes to the console, once, which |
| 101 | // is where a diagnostic belongs -- not beside somebody's money. |
| 102 | function catLabel(c) { |
| 103 | if (CATS.indexOf(c) !== -1) return t('spend.cat_' + c); |
| 104 | if (!c) return t('spend.cat_fallback'); // an entry that named nothing |
| 105 | if (!unnamed[c]) { |
| 106 | unnamed[c] = 1; |
| 107 | console.warn('spend: the gateway named a category this build cannot label: "' + c + '"'); |
| 108 | } |
| 109 | return t('spend.cat_unlisted'); |
| 110 | } |
| 111 | |
| 112 | // ── The SVG bar chart ────────────────────────────────────── |
| 113 | // Bars are laid out in a 0..100 × 0..100 viewBox and stretched to the |
| 114 | // container, so the CSS owns the size and the SVG owns only the shape. |
| 115 | // Colour comes from `currentColor` and a muted track, so it themes for free. |
| 116 | |
| 117 | function barChart(bars, opts) { |
| 118 | opts = opts || {}; |
| 119 | var W = 100, H = 100, n = bars.length; |
| 120 | var wrap = el('div', 'spend-chart'); |
| 121 | if (!n) { wrap.appendChild(el('div', 'spend-empty', opts.empty || t('spend.nothing_yet'))); return wrap; } |
| 122 | |
| 123 | var max = 0; |
| 124 | for (var i = 0; i < n; i++) max = Math.max(max, bars[i].value || 0); |
| 125 | if (max <= 0) max = 1; |
| 126 | |
| 127 | var gap = n > 40 ? 0.15 : (n > 14 ? 0.35 : 0.9); |
| 128 | var slot = W / n; |
| 129 | var bw = Math.max(0.5, slot - gap); |
| 130 | |
| 131 | var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); |
| 132 | svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); |
| 133 | svg.setAttribute('preserveAspectRatio', 'none'); |
| 134 | svg.setAttribute('class', 'spend-bars'); |
| 135 | |
| 136 | for (var k = 0; k < n; k++) { |
| 137 | var v = bars[k].value || 0; |
| 138 | var h = (v / max) * (H - 2); |
| 139 | var x = k * slot + (slot - bw) / 2; |
| 140 | // A faint full-height track so empty days read as "zero", not "missing". |
| 141 | var track = document.createElementNS(svg.namespaceURI, 'rect'); |
| 142 | track.setAttribute('x', x.toFixed(2)); |
| 143 | track.setAttribute('y', '0'); |
| 144 | track.setAttribute('width', bw.toFixed(2)); |
| 145 | track.setAttribute('height', String(H)); |
| 146 | track.setAttribute('class', 'spend-bar-track'); |
| 147 | svg.appendChild(track); |
| 148 | if (h > 0) { |
| 149 | var r = document.createElementNS(svg.namespaceURI, 'rect'); |
| 150 | r.setAttribute('x', x.toFixed(2)); |
| 151 | r.setAttribute('y', (H - h).toFixed(2)); |
| 152 | r.setAttribute('width', bw.toFixed(2)); |
| 153 | r.setAttribute('height', h.toFixed(2)); |
| 154 | r.setAttribute('class', 'spend-bar'); |
| 155 | var tip = document.createElementNS(svg.namespaceURI, 'title'); |
| 156 | tip.textContent = (bars[k].label ? bars[k].label + ': ' : '') + (opts.fmt ? opts.fmt(v) : v); |
| 157 | r.appendChild(tip); |
| 158 | svg.appendChild(r); |
| 159 | } |
| 160 | } |
| 161 | wrap.appendChild(svg); |
| 162 | |
| 163 | // A sparse axis: first and last labels only, so a 30-bar chart is not a |
| 164 | // smear of dates. |
| 165 | if (opts.axis !== false && n > 1) { |
| 166 | var axis = el('div', 'spend-axis'); |
| 167 | axis.appendChild(el('span', null, bars[0].label || '')); |
| 168 | axis.appendChild(el('span', null, bars[n - 1].label || '')); |
| 169 | wrap.appendChild(axis); |
| 170 | } |
| 171 | return wrap; |
| 172 | } |
| 173 | |
| 174 | // A horizontal category breakdown: label, proportional fill, and amount. |
| 175 | function breakdown(rows, fmt) { |
| 176 | var wrap = el('div', 'spend-breakdown'); |
| 177 | var total = 0, i; |
| 178 | for (i = 0; i < rows.length; i++) total += Math.abs(rows[i].value || 0); |
| 179 | if (total <= 0) { wrap.appendChild(el('div', 'spend-empty', t('spend.nothing_spent'))); return wrap; } |
| 180 | for (i = 0; i < rows.length; i++) { |
| 181 | var v = Math.abs(rows[i].value || 0); |
| 182 | if (v <= 0) continue; |
| 183 | var row = el('div', 'spend-bd-row'); |
| 184 | // The label column is one line and ellipses anything longer, and the |
| 185 | // dock is narrow: "Inference on credits" arrives as "Inference on…". |
| 186 | // The full text rides along as a tooltip so the row can still be read. |
| 187 | var lbl = el('span', 'spend-bd-label', rows[i].label); |
| 188 | lbl.title = rows[i].label; |
| 189 | row.appendChild(lbl); |
| 190 | var barWrap = el('span', 'spend-bd-bar'); |
| 191 | var fill = el('span', 'spend-bd-fill'); |
| 192 | fill.style.width = Math.max(2, (v / total) * 100).toFixed(1) + '%'; |
| 193 | barWrap.appendChild(fill); |
| 194 | row.appendChild(barWrap); |
| 195 | row.appendChild(el('span', 'spend-bd-amt', fmt(v))); |
| 196 | wrap.appendChild(row); |
| 197 | } |
| 198 | return wrap; |
| 199 | } |
| 200 | |
| 201 | function sectionHead(title, hint) { |
| 202 | var h = el('div', 'spend-sec-head'); |
| 203 | h.appendChild(el('h3', 'spend-sec-title', title)); |
| 204 | if (hint) h.appendChild(el('span', 'spend-sec-hint', hint)); |
| 205 | return h; |
| 206 | } |
| 207 | |
| 208 | // Whether a rolled-up total is entirely what the providers reported. Old |
| 209 | // entries carry no `r` and so are priced, not reported -- which is what they |
| 210 | // were, and the honest answer for them is still "≈". |
| 211 | function allReported(tot) { |
| 212 | if (!tot || !(tot.usd > 0)) return false; |
| 213 | var rep = tot.reportedUsd || 0; |
| 214 | return rep >= tot.usd - 1e-12; |
| 215 | } |
| 216 | |
| 217 | // The "≈" that precedes a figure, or nothing when the figure is a bill. A |
| 218 | // converted figure already carries its own ≈ from i18n, so this adds none. |
| 219 | function mark(tot) { |
| 220 | if (allReported(tot)) return ''; |
| 221 | if (window.DaimondI18n && DaimondI18n.converted()) return ''; |
| 222 | return '≈ '; |
| 223 | } |
| 224 | |
| 225 | // One line under the headline saying what the figure IS. |
| 226 | function provenance(tot) { |
| 227 | if (!tot || !(tot.usd > 0)) return ''; |
| 228 | if (allReported(tot)) return t('spend.all_reported'); |
| 229 | if ((tot.reportedUsd || 0) > 0) { |
| 230 | return t('spend.part_reported', { amount: fmtUsd(tot.reportedUsd) }); |
| 231 | } |
| 232 | return tot.estimated ? t('spend.none_reported_unknown') : t('spend.none_reported'); |
| 233 | } |
| 234 | |
| 235 | // A second line, when the figures above have MOVED since the user last read |
| 236 | // them. Estimates made under the old rate table -- the one that ran about six |
| 237 | // times high -- were re-priced once on this device, which cut every total |
| 238 | // containing one. Dropping a number by that much without a word is how a |
| 239 | // meter loses its reader, so the period says what it used to read. |
| 240 | // |
| 241 | // `tot` is the period's total as it stands. What it read before is that |
| 242 | // total with the touched turns put back at their original guess. Nothing is |
| 243 | // said when the period holds no repriced turn, nor when the two figures |
| 244 | // round to the same thing: an explanation of a change nobody can see is |
| 245 | // noise, and this way the line ages out as the 90-day log retires the old |
| 246 | // entries. |
| 247 | function repriceNote(tot) { |
| 248 | var L = window.DaimondLedger; |
| 249 | if (!L || typeof L.repriced !== 'function' || !tot) return ''; |
| 250 | var rp; |
| 251 | try { rp = L.repriced(period); } catch (e) { return ''; } |
| 252 | if (!rp || !rp.turns) return ''; |
| 253 | var before = (tot.usd || 0) - (rp.usd || 0) + (rp.was || 0); |
| 254 | var was = fmtUsd(before); |
| 255 | if (was === fmtUsd(tot.usd || 0)) return ''; |
| 256 | // The old figure was a guess, and says so -- unless the currency |
| 257 | // conversion has already hung a ≈ on it. |
| 258 | var approx = (window.DaimondI18n && DaimondI18n.converted()) ? '' : '≈ '; |
| 259 | return t('spend.repriced', { amount: approx + was }); |
| 260 | } |
| 261 | |
| 262 | // ── Inference section (from DaimondLedger) ───────────────── |
| 263 | |
| 264 | function inferenceSection() { |
| 265 | var sec = el('section', 'spend-sec'); |
| 266 | sec.appendChild(sectionHead(t('spend.inference'), t('spend.inference_hint'))); |
| 267 | |
| 268 | var L = window.DaimondLedger; |
| 269 | if (!L) { sec.appendChild(el('div', 'spend-empty', t('spend.no_usage'))); return sec; } |
| 270 | |
| 271 | var totals = {}; |
| 272 | try { totals = L.totals() || {}; } catch (e) { totals = {}; } |
| 273 | var win = totals[period] || { usd: 0, tokens: 0 }; |
| 274 | |
| 275 | // The headline: this period's spend, plus session for immediacy. |
| 276 | var head = el('div', 'spend-totals'); |
| 277 | var periodLbl = period === 'week' ? t('spend.this_week') : t('spend.this_month'); |
| 278 | // A converted figure already carries its own ≈, so the estimate mark is |
| 279 | // not added a second time. |
| 280 | var approx = (window.DaimondI18n && DaimondI18n.converted()) ? '' : '≈ '; |
| 281 | head.appendChild(bigStat(mark(win) + fmtUsd(win.usd), periodLbl)); |
| 282 | if (totals.session) { |
| 283 | head.appendChild(bigStat(mark(totals.session) + fmtUsd(totals.session.usd), t('spend.session'))); |
| 284 | } |
| 285 | head.appendChild(bigStat(fmtTokens(win.tokens) + ' ' + t('spend.tok'), periodLbl)); |
| 286 | sec.appendChild(head); |
| 287 | |
| 288 | // Say where the figure came from, once, under the headline. A total the |
| 289 | // providers themselves billed is a fact, and dressing it in a "≈" was |
| 290 | // telling the user it was guesswork when it was the opposite. |
| 291 | var prov = provenance(win); |
| 292 | if (prov) sec.appendChild(el('div', 'spend-note', prov)); |
| 293 | |
| 294 | // And, while the correction is still in view, why the figure fell. |
| 295 | var rn = repriceNote(win); |
| 296 | if (rn) sec.appendChild(el('div', 'spend-note spend-reprice', rn)); |
| 297 | |
| 298 | // The period toggle. |
| 299 | var toggle = el('div', 'spend-toggle'); |
| 300 | ['week', 'month'].forEach(function (key) { |
| 301 | var p = [key]; |
| 302 | var b = el('button', 'spend-toggle-btn' + (period === p[0] ? ' on' : ''), t('spend.period_' + key)); |
| 303 | b.onclick = function () { period = p[0]; render(); }; |
| 304 | toggle.appendChild(b); |
| 305 | }); |
| 306 | sec.appendChild(toggle); |
| 307 | |
| 308 | // The daily time graph. |
| 309 | var days = period === 'week' ? 7 : 30; |
| 310 | var ser = []; |
| 311 | try { ser = L.series(days) || []; } catch (e) { ser = []; } |
| 312 | var bars = ser.map(function (d) { |
| 313 | var dd = new Date(d.ts); |
| 314 | return { value: d.usd, label: dd.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }; |
| 315 | }); |
| 316 | sec.appendChild(barChart(bars, { fmt: fmtUsd, empty: t('spend.no_turns') })); |
| 317 | |
| 318 | // The by-model table. |
| 319 | var byModel = []; |
| 320 | try { byModel = L.perModel(period) || []; } catch (e) { byModel = []; } |
| 321 | if (byModel.length) { |
| 322 | var tbl = el('table', 'spend-table'); |
| 323 | var thead = el('tr'); |
| 324 | [t('spend.col_model'), t('spend.col_turns'), t('spend.col_tokens'), t('spend.col_cost')].forEach(function (h, i) { |
| 325 | var th = el('th', i > 0 ? 'num' : null, h); thead.appendChild(th); |
| 326 | }); |
| 327 | var thd = el('thead'); thd.appendChild(thead); tbl.appendChild(thd); |
| 328 | var tb = el('tbody'); |
| 329 | byModel.forEach(function (m) { |
| 330 | var tr = el('tr'); |
| 331 | tr.appendChild(el('td', 'spend-model', m.model || t('spend.unknown_model'))); |
| 332 | tr.appendChild(el('td', 'num', String(m.turns))); |
| 333 | tr.appendChild(el('td', 'num', fmtTokens(m.tokens))); |
| 334 | tr.appendChild(el('td', 'num', fmtUsd(m.usd))); |
| 335 | tb.appendChild(tr); |
| 336 | }); |
| 337 | tbl.appendChild(tb); |
| 338 | sec.appendChild(tbl); |
| 339 | } |
| 340 | |
| 341 | // What is left on each key, beside what this period drew from it. The |
| 342 | // credits row has had this since it existed; a user's own key had spend |
| 343 | // with nothing to measure it against, which is the half of the question |
| 344 | // that decides whether they can keep working. |
| 345 | var keys = providerKeys(); |
| 346 | if (keys.length) { |
| 347 | sec.appendChild(el('div', 'spend-sub', t('spend.provider_keys'))); |
| 348 | var ktbl = el('table', 'spend-table'); |
| 349 | var khead = el('tr'); |
| 350 | [t('spend.col_key'), t('spend.col_left'), t('spend.col_period_spend')].forEach(function (h, i) { |
| 351 | khead.appendChild(el('th', i > 0 ? 'num' : null, h)); |
| 352 | }); |
| 353 | var kthd = el('thead'); kthd.appendChild(khead); ktbl.appendChild(kthd); |
| 354 | var ktb = el('tbody'); |
| 355 | keys.forEach(function (k) { |
| 356 | var tr = el('tr'); |
| 357 | tr.appendChild(el('td', 'spend-model', k.name)); |
| 358 | var left = el('td', 'num', k.left); |
| 359 | if (k.leftHint) left.title = k.leftHint; |
| 360 | tr.appendChild(left); |
| 361 | tr.appendChild(el('td', 'num', fmtUsd(k.spent))); |
| 362 | ktb.appendChild(tr); |
| 363 | }); |
| 364 | ktbl.appendChild(ktb); |
| 365 | sec.appendChild(ktbl); |
| 366 | } |
| 367 | return sec; |
| 368 | } |
| 369 | |
| 370 | // Each provider key, with what is left on it and what this period drew. |
| 371 | // |
| 372 | // A key nobody can say anything about shows an em dash, never a zero: "we do |
| 373 | // not know" and "you have nothing" are opposite messages and the second one |
| 374 | // would send a working user hunting for a top-up they do not need. |
| 375 | function providerKeys() { |
| 376 | var M = window.DaimondModels, L = window.DaimondLedger; |
| 377 | if (!M || typeof M.providers !== 'function' || !L |
| 378 | || typeof L.perProvider !== 'function') return []; |
| 379 | var from = Date.now() - (period === 'week' ? 7 : 30) * 24 * 60 * 60 * 1000; |
| 380 | var spentBy = {}; |
| 381 | try { |
| 382 | L.perProvider(from).forEach(function (row) { spentBy[row.provider] = row.usd || 0; }); |
| 383 | } catch (e) { spentBy = {}; } |
| 384 | var out = []; |
| 385 | try { |
| 386 | M.providers().forEach(function (p) { |
| 387 | var spent = spentBy[p.id] || 0; |
| 388 | // A row with neither a balance nor any spend has nothing to say. |
| 389 | if (!p.balance && spent <= 0) return; |
| 390 | out.push({ |
| 391 | name: p.name, |
| 392 | left: p.balance || '—', |
| 393 | leftHint: p.creditMode === 'manual' ? t('spend.left_manual') |
| 394 | : p.creditMode === 'auto' ? t('spend.left_auto') |
| 395 | : p.minted ? '' : t('spend.left_unknown'), |
| 396 | spent: spent, |
| 397 | }); |
| 398 | }); |
| 399 | } catch (e) { return []; } |
| 400 | out.sort(function (a, b) { return b.spent - a.spent; }); |
| 401 | return out; |
| 402 | } |
| 403 | |
| 404 | function bigStat(value, label) { |
| 405 | var s = el('div', 'spend-stat'); |
| 406 | s.appendChild(el('span', 'spend-stat-val', value)); |
| 407 | s.appendChild(el('span', 'spend-stat-lbl', label)); |
| 408 | return s; |
| 409 | } |
| 410 | |
| 411 | // ── Credits section (from /api/ledger) ───────────────────── |
| 412 | |
| 413 | function creditsSection() { |
| 414 | var sec = el('section', 'spend-sec'); |
| 415 | sec.appendChild(sectionHead(t('spend.credits'), t('spend.credits_hint'))); |
| 416 | |
| 417 | var g = window.DaimondGateway; |
| 418 | var st = (g && g.state) ? g.state() : { authed: false }; |
| 419 | |
| 420 | if (!st.authed) { |
| 421 | var note = el('div', 'spend-note'); |
| 422 | note.textContent = t('spend.no_account'); |
| 423 | sec.appendChild(note); |
| 424 | return sec; |
| 425 | } |
| 426 | |
| 427 | // Balance headline. |
| 428 | var head = el('div', 'spend-totals'); |
| 429 | head.appendChild(bigStat(fmtCredits(st.credits || 0), t('spend.balance'))); |
| 430 | sec.appendChild(head); |
| 431 | |
| 432 | // Category breakdown of spends only (debits are negative deltas). |
| 433 | var byCat = {}; |
| 434 | var movements = 0; |
| 435 | creditEntries.forEach(function (e) { |
| 436 | var d = e.delta_minor || 0; |
| 437 | if (d < 0) { |
| 438 | var c = e.category || 'other'; |
| 439 | byCat[c] = (byCat[c] || 0) + (-d); |
| 440 | } |
| 441 | movements++; |
| 442 | }); |
| 443 | var catRows = Object.keys(byCat) |
| 444 | .map(function (c) { return { label: catLabel(c), value: byCat[c] }; }) |
| 445 | .sort(function (a, b) { return b.value - a.value; }); |
| 446 | sec.appendChild(el('div', 'spend-sub', t('spend.where_credits_went'))); |
| 447 | sec.appendChild(breakdown(catRows, fmtCredits)); |
| 448 | |
| 449 | // The movements table: the ledger itself, plainly. |
| 450 | if (movements) { |
| 451 | var tbl = el('table', 'spend-table'); |
| 452 | var thead = el('tr'); |
| 453 | [t('spend.col_when'), t('spend.col_what'), t('spend.col_amount'), t('spend.col_balance')].forEach(function (h, i) { |
| 454 | thead.appendChild(el('th', i > 1 ? 'num' : null, h)); |
| 455 | }); |
| 456 | var thd = el('thead'); thd.appendChild(thead); tbl.appendChild(thd); |
| 457 | var tb = el('tbody'); |
| 458 | creditEntries.slice(0, 40).forEach(function (e) { |
| 459 | var tr = el('tr'); |
| 460 | tr.appendChild(el('td', 'spend-when', fmtWhen(e.ts / 1e6))); // ts is ns |
| 461 | tr.appendChild(el('td', null, catLabel(e.category || e.kind))); |
| 462 | var d = e.delta_minor || 0; |
| 463 | var amt = el('td', 'num ' + (d < 0 ? 'debit' : 'credit'), |
| 464 | (d < 0 ? '−' : '+') + fmtCredits(Math.abs(d))); |
| 465 | tr.appendChild(amt); |
| 466 | tr.appendChild(el('td', 'num', fmtCredits(e.balance || 0))); |
| 467 | tb.appendChild(tr); |
| 468 | }); |
| 469 | tbl.appendChild(tb); |
| 470 | sec.appendChild(tbl); |
| 471 | } else { |
| 472 | sec.appendChild(el('div', 'spend-empty', t('spend.no_movements'))); |
| 473 | } |
| 474 | return sec; |
| 475 | } |
| 476 | |
| 477 | // ── Render ───────────────────────────────────────────────── |
| 478 | |
| 479 | function render() { |
| 480 | var host = document.getElementById('spend-view'); |
| 481 | if (!host) return; |
| 482 | host.innerHTML = ''; |
| 483 | // Frame the two pots before the numbers, so nobody reads them as one sum. |
| 484 | host.appendChild(el('div', 'spend-intro', t('spend.intro'))); |
| 485 | host.appendChild(inferenceSection()); |
| 486 | host.appendChild(el('div', 'spend-divider')); |
| 487 | host.appendChild(creditsSection()); |
| 488 | // A meter is an estimate either way, but a converted one is an estimate |
| 489 | // twice over, so it says which rates it used. |
| 490 | if (window.DaimondI18n && DaimondI18n.currency() !== 'USD') { |
| 491 | host.appendChild(el('div', 'spend-note', |
| 492 | t('spend.rates_note', { date: DaimondI18n.ratesAsOf() }))); |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | function wireActions() { |
| 497 | if (wiredActions) return; |
| 498 | var panel = document.getElementById('panel-spend'); |
| 499 | if (!panel) return; |
| 500 | // The refresh button re-pulls and re-draws. |
| 501 | panel.addEventListener('click', function (ev) { |
| 502 | var b = ev.target.closest && ev.target.closest('[data-act="spend-refresh"]'); |
| 503 | if (b) { ev.preventDefault(); onOpen(); } |
| 504 | }); |
| 505 | // The header spend meter is the door to this view. Wire it once, at load, |
| 506 | // so it opens the panel whenever it is visible -- independently of when |
| 507 | // the meter's figures are (re)drawn. |
| 508 | var row = document.getElementById('spend-row'); |
| 509 | if (row) { |
| 510 | row.setAttribute('role', 'button'); |
| 511 | row.setAttribute('tabindex', '0'); |
| 512 | row.title = t('spend.meter_help'); |
| 513 | row.addEventListener('click', show); |
| 514 | row.addEventListener('keydown', function (ev) { |
| 515 | if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); show(); } |
| 516 | }); |
| 517 | } |
| 518 | wiredActions = true; |
| 519 | } |
| 520 | |
| 521 | /// Called when the panel is revealed: pull the freshest numbers, then draw. |
| 522 | /// The inference side is local and instant; the credit side is a fetch, so |
| 523 | /// the view renders at once and fills the credit table in when it lands. |
| 524 | async function onOpen() { |
| 525 | wireActions(); |
| 526 | render(); // instant, from local data |
| 527 | var g = window.DaimondGateway; |
| 528 | if (g && g.state && g.state().authed) { |
| 529 | try { await g.refreshBalance(); } catch (e) {} |
| 530 | try { creditEntries = await g.ledger(); } catch (e) { creditEntries = []; } |
| 531 | render(); // redraw with credit history |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | /// Open the Spending panel (used by the header meter's click). |
| 536 | /// |
| 537 | /// When the panel is already open, `DaimondPanels.show` is a no-op and its |
| 538 | /// render hook does not fire, so refresh explicitly in that case -- a click |
| 539 | /// on the meter always leaves a freshly-drawn view. |
| 540 | function show() { |
| 541 | var P = window.DaimondPanels; |
| 542 | var wasOpen = !!(P && P.isOpen && P.isOpen('spend')); |
| 543 | if (P) P.show('spend'); else onOpen(); |
| 544 | if (wasOpen) onOpen(); |
| 545 | } |
| 546 | |
| 547 | window.DaimondSpend = { |
| 548 | onOpen: onOpen, |
| 549 | refresh: onOpen, |
| 550 | show: show, |
| 551 | }; |
| 552 | |
| 553 | // A language or currency change redraws the panel if it is on screen; a |
| 554 | // closed one is rebuilt from scratch when it next opens. |
| 555 | if (window.DaimondI18n) { |
| 556 | DaimondI18n.onChange(function () { |
| 557 | if (document.getElementById('spend-view')) render(); |
| 558 | }); |
| 559 | } |
| 560 | |
| 561 | // Wire the header meter at load, so it opens the view the first time it is |
| 562 | // clicked, before the panel has ever been shown. |
| 563 | if (document.readyState === 'loading') { |
| 564 | document.addEventListener('DOMContentLoaded', wireActions); |
| 565 | } else { |
| 566 | wireActions(); |
| 567 | } |
| 568 | })(); |