index.js

  1var CONFIG_BASE_URL = "plexusBaseUrl";
  2var CONFIG_ADMIN_KEY = "plexusAdminKey";
  3var MAX_ROWS_PER_GROUP = 4;
  4var quotaGroups = [];
  5var currentPageIndex = 0;
  6var providerDisplayNames = {};
  7
  8var ROW_KEYS = [
  9  {
 10    label: "ROW_0_LABEL",
 11    detail: "ROW_0_DETAIL",
 12    percent: "ROW_0_PERCENT",
 13    status: "ROW_0_STATUS",
 14    hasBar: "ROW_0_HAS_BAR"
 15  },
 16  {
 17    label: "ROW_1_LABEL",
 18    detail: "ROW_1_DETAIL",
 19    percent: "ROW_1_PERCENT",
 20    status: "ROW_1_STATUS",
 21    hasBar: "ROW_1_HAS_BAR"
 22  },
 23  {
 24    label: "ROW_2_LABEL",
 25    detail: "ROW_2_DETAIL",
 26    percent: "ROW_2_PERCENT",
 27    status: "ROW_2_STATUS",
 28    hasBar: "ROW_2_HAS_BAR"
 29  },
 30  {
 31    label: "ROW_3_LABEL",
 32    detail: "ROW_3_DETAIL",
 33    percent: "ROW_3_PERCENT",
 34    status: "ROW_3_STATUS",
 35    hasBar: "ROW_3_HAS_BAR"
 36  }
 37];
 38
 39function trimSlash(value) {
 40  return String(value || "").replace(/\/+$/, "");
 41}
 42
 43function sendError(message) {
 44  Pebble.sendAppMessage({ ERROR: String(message).slice(0, 38) });
 45}
 46
 47function xhrRequest(url, adminKey, callback) {
 48  var xhr = new XMLHttpRequest();
 49  xhr.onload = function () {
 50    if (xhr.status < 200 || xhr.status >= 300) {
 51      callback(new Error("HTTP " + xhr.status), null);
 52      return;
 53    }
 54    callback(null, xhr.responseText);
 55  };
 56  xhr.onerror = function () {
 57    callback(new Error("Network error"), null);
 58  };
 59  xhr.open("GET", url);
 60  xhr.setRequestHeader("x-admin-key", adminKey);
 61  xhr.send();
 62}
 63
 64function asNumber(value) {
 65  if (typeof value === "number" && isFinite(value)) return value;
 66  if (typeof value === "string" && value !== "" && isFinite(Number(value)))
 67    return Number(value);
 68  return null;
 69}
 70
 71function statusRank(status) {
 72  if (status === "critical" || status === "exhausted") return 4;
 73  if (status === "warning") return 3;
 74  if (status === "unknown") return 2;
 75  if (status === "ok") return 1;
 76  return 0;
 77}
 78
 79function meterUrgency(meter) {
 80  var rank = statusRank(meter.status);
 81  var percent = asNumber(meter.utilizationPercent);
 82  if (percent === null) percent = 0;
 83  return rank * 1000 + percent;
 84}
 85
 86function groupHasComparableMeter(group) {
 87  for (var i = 0; i < group.meters.length; i += 1) {
 88    if (group.meters[i].hasBar) return true;
 89  }
 90  return false;
 91}
 92
 93function groupUrgency(group) {
 94  var urgency = 0;
 95  for (var i = 0; i < group.meters.length; i += 1) {
 96    if (!group.meters[i].hasBar) continue;
 97    urgency = Math.max(urgency, meterUrgency(group.meters[i]));
 98  }
 99  return urgency;
100}
101
102function clampPageIndex(pageIndex, pageCount) {
103  var index = asNumber(pageIndex);
104  if (index === null) index = currentPageIndex;
105  index = Math.round(index);
106  if (pageCount <= 0) return 0;
107  if (index < 0) return pageCount - 1;
108  if (index >= pageCount) return 0;
109  return index;
110}
111
112function formatNumber(value) {
113  var number = asNumber(value);
114  if (number === null) return "?";
115  if (Math.abs(number) >= 1000)
116    return String(Math.round(number).toLocaleString());
117  if (Math.abs(number) >= 100) return String(Math.round(number));
118  if (Math.abs(number) >= 10) return String(Math.round(number * 10) / 10);
119  return String(Math.round(number * 100) / 100);
120}
121
122function formatCompactNumber(value) {
123  var number = asNumber(value);
124  if (number === null) return "?";
125  var absolute = Math.abs(number);
126  if (absolute >= 1000000)
127    return String(Math.round(number / 100000) / 10) + "m";
128  if (absolute >= 1000) return String(Math.round(number / 100) / 10) + "k";
129  return formatNumber(number);
130}
131
132var UNIT_SUFFIXES = {
133  hypercredits: "hc",
134  requests: "req",
135  request: "req",
136  tokens: "tok",
137  token: "tok",
138  kwh: "kWh"
139};
140
141function formatValue(value, unit) {
142  var number = asNumber(value);
143  if (number === null) return "?";
144  if (unit === "usd") return "$" + number.toFixed(2);
145  if (unit === "percent" || unit === "percentage")
146    return formatNumber(number) + "%";
147  if (!unit) return formatNumber(number);
148  var suffix = UNIT_SUFFIXES[String(unit).toLowerCase()] || String(unit);
149  return formatCompactNumber(number) + " " + suffix;
150}
151
152function formatMeterDetail(meter) {
153  var percent = asNumber(meter.utilizationPercent);
154  if (meter.hasBar && percent !== null)
155    return String(Math.round(percent)) + "%";
156
157  var remaining = asNumber(meter.remaining);
158  if (remaining !== null) return formatValue(remaining, meter.unit);
159
160  var limit = asNumber(meter.limit);
161  if (limit !== null) return formatValue(limit, meter.unit);
162
163  return "—";
164}
165
166var LABEL_ABBREVIATIONS = {
167  requests: "req",
168  request: "req",
169  tokens: "tok",
170  token: "tok",
171  messages: "msg",
172  message: "msg",
173  minutes: "min",
174  minute: "min",
175  seconds: "sec",
176  second: "sec",
177  hours: "hr",
178  hour: "hr"
179};
180
181var LABEL_CHAR_BUDGET = 14;
182
183// Drop label words the page title already shows ("OpenAI requests" on the
184// OpenAI page is just "requests").
185function stripTitleWords(label, title) {
186  var titleWords = {};
187  var titleTokens = String(title || "")
188    .toLowerCase()
189    .split(/[^a-z0-9]+/);
190  for (var i = 0; i < titleTokens.length; i += 1) {
191    if (titleTokens[i]) titleWords[titleTokens[i]] = true;
192  }
193
194  var words = String(label).split(/\s+/);
195  var kept = [];
196  for (var j = 0; j < words.length; j += 1) {
197    var bare = words[j].toLowerCase().replace(/[^a-z0-9]/g, "");
198    if (bare === "" || !titleWords[bare]) kept.push(words[j]);
199  }
200  if (kept.length === 0) return String(label);
201  return kept.join(" ");
202}
203
204function abbreviateLabel(label) {
205  var words = label.split(/\s+/);
206  var out = [];
207  for (var i = 0; i < words.length; i += 1) {
208    out.push(LABEL_ABBREVIATIONS[words[i].toLowerCase()] || words[i]);
209  }
210  return out.join(" ").replace(/(\S+) per (\S+)/gi, "$1/$2");
211}
212
213function displayLabelForMeter(meter, title) {
214  var label = stripTitleWords(meter.label, title);
215  if (label.length > LABEL_CHAR_BUDGET) label = abbreviateLabel(label);
216  return label.charAt(0).toUpperCase() + label.slice(1);
217}
218
219// Drop the detail's unit suffix when the label already names the unit
220// ("Req/min" plus "30 req" reads fine as "Req/min" plus "30").
221function displayDetailForMeter(meter, displayLabel) {
222  var detail = formatMeterDetail(meter);
223  var spaceIndex = detail.lastIndexOf(" ");
224  if (spaceIndex === -1) return detail;
225  var suffix = detail.slice(spaceIndex + 1).toLowerCase();
226  var label = String(displayLabel || "").toLowerCase();
227  var unit = String(meter.unit || "").toLowerCase();
228  if (label.indexOf(suffix) !== -1 || (unit !== "" && label.indexOf(unit) !== -1))
229    return detail.slice(0, spaceIndex);
230  return detail;
231}
232
233function shortResetForGroup(group) {
234  var soonest = null;
235  for (var i = 0; i < group.meters.length; i += 1) {
236    if (!group.meters[i].resetsAt) continue;
237    var reset = new Date(group.meters[i].resetsAt);
238    if (!isFinite(reset.getTime())) continue;
239    if (soonest === null || reset.getTime() < soonest.getTime())
240      soonest = reset;
241  }
242  if (soonest === null) return "No reset time";
243
244  var diffMs = soonest.getTime() - Date.now();
245  var prefix = diffMs >= 0 ? "Next reset " : "Reset ";
246  var absMinutes = Math.round(Math.abs(diffMs) / 60000);
247  if (absMinutes < 90) return prefix + absMinutes + "m";
248  var absHours = Math.round(absMinutes / 60);
249  if (absHours < 48) return prefix + absHours + "h";
250  return prefix + Math.round(absHours / 24) + "d";
251}
252
253function hasComparableLimit(meter) {
254  var percent = asNumber(meter.utilizationPercent);
255  var limit = asNumber(meter.limit);
256  if (percent === null) return false;
257  if (meter.kind === "balance") return false;
258  if (meter.unit === "percent" || meter.unit === "percentage") return true;
259  if (limit === null || limit <= 0) return false;
260  return true;
261}
262
263function normalizeMeter(quota, meter) {
264  var percent = asNumber(meter.utilizationPercent);
265  return {
266    checkerId: quota.checkerId || quota.provider || "unknown",
267    provider: quota.provider || quota.checkerId || "unknown",
268    label: meter.label || meter.key || "Quota",
269    kind: meter.kind || "unknown",
270    status: meter.status || "unknown",
271    unit: meter.unit || "",
272    limit: meter.limit,
273    remaining: meter.remaining,
274    utilizationPercent: percent,
275    resetsAt: meter.resetsAt,
276    hasBar: hasComparableLimit(meter)
277  };
278}
279
280function displayNameForQuota(quota, id, displayNames) {
281  return (
282    displayNames[id] ||
283    quota.display_name ||
284    quota.displayName ||
285    quota.name ||
286    quota.providerName ||
287    quota.provider ||
288    id
289  );
290}
291
292function compareText(left, right) {
293  var normalizedLeft = String(left || "").toLowerCase();
294  var normalizedRight = String(right || "").toLowerCase();
295  if (normalizedLeft < normalizedRight) return -1;
296  if (normalizedLeft > normalizedRight) return 1;
297  return 0;
298}
299
300function compareMeters(left, right) {
301  if (left.hasBar !== right.hasBar) return left.hasBar ? -1 : 1;
302  var urgencyDelta = meterUrgency(right) - meterUrgency(left);
303  if (urgencyDelta !== 0) return urgencyDelta;
304  return compareText(left.label, right.label);
305}
306
307function flattenGroups(response, displayNames) {
308  var quotas = JSON.parse(response);
309  var groupsById = {};
310  var groups = [];
311
312  for (var i = 0; i < quotas.length; i += 1) {
313    var quota = quotas[i];
314    if (!quota || quota.success !== true || !quota.meters) continue;
315    var id = quota.checkerId || quota.provider || "unknown";
316    if (!groupsById[id]) {
317      groupsById[id] = {
318        id: id,
319        title: displayNameForQuota(quota, id, displayNames || {}),
320        meters: []
321      };
322      groups.push(groupsById[id]);
323    }
324    for (var j = 0; j < quota.meters.length; j += 1) {
325      if (quota.meters[j])
326        groupsById[id].meters.push(normalizeMeter(quota, quota.meters[j]));
327    }
328  }
329
330  for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
331    var group = groups[groupIndex];
332    group.meters.sort(compareMeters);
333    for (var meterIndex = 0; meterIndex < group.meters.length; meterIndex += 1) {
334      var meter = group.meters[meterIndex];
335      meter.displayLabel = displayLabelForMeter(meter, group.title);
336      meter.displayDetail = displayDetailForMeter(meter, meter.displayLabel);
337    }
338  }
339  groups.sort(function (left, right) {
340    var leftHasComparableMeter = groupHasComparableMeter(left);
341    var rightHasComparableMeter = groupHasComparableMeter(right);
342    if (leftHasComparableMeter !== rightHasComparableMeter)
343      return leftHasComparableMeter ? -1 : 1;
344    var urgencyDelta = groupUrgency(right) - groupUrgency(left);
345    if (urgencyDelta !== 0) return urgencyDelta;
346    return compareText(left.id, right.id);
347  });
348
349  return groups;
350}
351
352// Mirrors the watch row layout in src/c/main.c: bar rows are 42px, one-line
353// rows 28px, two-line rows 50px, with 3px separators between rows. Rows run
354// from y=60 (y=42 without the status pill row) down to the reset line at
355// height-34. Keep in sync.
356var PAGE_HEIGHT_BUDGET_WITH_STATUS_PX = 134;
357var PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX = 152;
358var ROW_SEPARATOR_PX = 3;
359var TWO_LINE_LABEL_CHAR_ESTIMATE = 14;
360
361function estimateRowHeightPx(meter) {
362  if (meter.hasBar) return 42;
363  return String(meter.displayLabel).length > TWO_LINE_LABEL_CHAR_ESTIMATE
364    ? 50
365    : 28;
366}
367
368// Split each group into as many pages as its meters need, so nothing hides
369// behind "+ more". Meters are already sorted most-urgent-first, so part one
370// always carries the worst news.
371function paginateGroups(groups) {
372  var pages = [];
373  for (var g = 0; g < groups.length; g += 1) {
374    var group = groups[g];
375    var parts = [];
376    var current = null;
377    var budget = 0;
378    for (var m = 0; m < group.meters.length; m += 1) {
379      var meter = group.meters[m];
380      var rowHeight = estimateRowHeightPx(meter);
381      var fits =
382        current !== null &&
383        current.meters.length < MAX_ROWS_PER_GROUP &&
384        current.heightPx + ROW_SEPARATOR_PX + rowHeight <= budget;
385      if (fits) {
386        current.heightPx += ROW_SEPARATOR_PX + rowHeight;
387      } else {
388        budget = meter.hasBar
389          ? PAGE_HEIGHT_BUDGET_WITH_STATUS_PX
390          : PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX;
391        current = {
392          id: group.id,
393          title: group.title,
394          meters: [],
395          heightPx: rowHeight
396        };
397        parts.push(current);
398      }
399      current.meters.push(meter);
400    }
401    for (var p = 0; p < parts.length; p += 1) {
402      parts[p].partIndex = p + 1;
403      parts[p].partCount = parts.length;
404      pages.push(parts[p]);
405    }
406  }
407  return pages;
408}
409
410function resetTextForPage(page) {
411  var reset = shortResetForGroup(page);
412  if (page.partCount > 1)
413    return page.partIndex + "/" + page.partCount + " · " + reset;
414  return reset;
415}
416
417function sendGroupPage(pageIndex) {
418  if (quotaGroups.length === 0) {
419    sendError("No quota groups found");
420    return;
421  }
422
423  currentPageIndex = clampPageIndex(pageIndex, quotaGroups.length);
424  var group = quotaGroups[currentPageIndex];
425  var dictionary = {
426    QUOTA_TITLE: String(group.title).slice(0, 38),
427    QUOTA_STATUS: "",
428    QUOTA_RESET: resetTextForPage(group).slice(0, 38),
429    QUOTA_LABEL: "",
430    ROW_COUNT: group.meters.length,
431    PAGE_INDEX: currentPageIndex,
432    PAGE_COUNT: quotaGroups.length,
433    UPDATED_AT: currentTimeText()
434  };
435
436  var worstStatusRank = 1;
437  var pageHasBar = false;
438  var rowsToSend = Math.min(group.meters.length, MAX_ROWS_PER_GROUP);
439  for (var i = 0; i < rowsToSend; i += 1) {
440    var meter = group.meters[i];
441    var keys = ROW_KEYS[i];
442    var rank = statusRank(meter.status);
443    if (meter.hasBar) pageHasBar = true;
444    worstStatusRank = Math.max(worstStatusRank, rank);
445    dictionary[keys.label] = String(meter.displayLabel).slice(0, 22);
446    dictionary[keys.detail] = String(meter.displayDetail).slice(0, 26);
447    dictionary[keys.percent] = Math.max(
448      0,
449      Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0))
450    );
451    dictionary[keys.status] = rank;
452    dictionary[keys.hasBar] = meter.hasBar ? 1 : 0;
453  }
454
455  if (pageHasBar) {
456    if (worstStatusRank >= 4) dictionary.QUOTA_STATUS = "CRITICAL";
457    else if (worstStatusRank >= 3) dictionary.QUOTA_STATUS = "WARNING";
458    else dictionary.QUOTA_STATUS = "OK";
459  }
460
461  Pebble.sendAppMessage(dictionary);
462}
463
464function currentTimeText() {
465  var now = new Date();
466  var minutes = now.getMinutes();
467  return (
468    "at " + now.getHours() + ":" + (minutes < 10 ? "0" + minutes : minutes)
469  );
470}
471
472function parseProviderDisplayNames(responseText) {
473  var providers = JSON.parse(responseText);
474  var displayNames = {};
475  if (!providers || typeof providers !== "object") return displayNames;
476
477  for (var id in providers) {
478    if (!Object.prototype.hasOwnProperty.call(providers, id)) continue;
479    var provider = providers[id];
480    if (!provider || typeof provider !== "object") continue;
481    var displayName =
482      provider.display_name || provider.displayName || provider.name;
483    if (displayName) displayNames[id] = String(displayName);
484  }
485
486  return displayNames;
487}
488
489function refreshQuotaWithDisplayNames(
490  baseUrl,
491  adminKey,
492  pageIndex,
493  displayNames
494) {
495  xhrRequest(
496    baseUrl + "/v0/management/quotas",
497    adminKey,
498    function (error, responseText) {
499      if (error) {
500        sendError(error.message);
501        return;
502      }
503      try {
504        quotaGroups = paginateGroups(flattenGroups(responseText, displayNames));
505        sendGroupPage(pageIndex);
506      } catch (e) {
507        sendError("Bad quota response");
508      }
509    }
510  );
511}
512
513function refreshQuota(pageIndex) {
514  var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL));
515  var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY);
516  if (!baseUrl || !adminKey) {
517    sendError("Set URL and admin key");
518    return;
519  }
520
521  xhrRequest(
522    baseUrl + "/v0/management/providers",
523    adminKey,
524    function (providerError, providerResponseText) {
525      var displayNames = providerDisplayNames;
526      try {
527        if (!providerError) {
528          displayNames = parseProviderDisplayNames(providerResponseText);
529          providerDisplayNames = displayNames;
530        }
531      } catch (e) {
532        displayNames = providerDisplayNames;
533      }
534      refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames);
535    }
536  );
537}
538
539function htmlEscape(value) {
540  return String(value || "")
541    .replace(/&/g, "&amp;")
542    .replace(/</g, "&lt;")
543    .replace(/>/g, "&gt;")
544    .replace(/"/g, "&quot;");
545}
546
547function configurationHtml() {
548  var baseUrl = htmlEscape(localStorage.getItem(CONFIG_BASE_URL));
549  var adminKey = htmlEscape(localStorage.getItem(CONFIG_ADMIN_KEY));
550  return (
551    '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">' +
552    "<title>Pebblexus</title><style>body{font:16px sans-serif;margin:1rem;line-height:1.4}" +
553    "label{display:block;margin:1rem 0 .25rem}input{box-sizing:border-box;width:100%;font:inherit;padding:.5rem}" +
554    "button{font:inherit;margin-top:1rem;padding:.7rem 1rem;width:100%}.hint{color:#555;font-size:.9rem}</style></head>" +
555    '<body><h1>Pebblexus</h1><p class="hint">Your admin key stays in PebbleKit JS storage on this phone and is not sent to the watch.</p>' +
556    '<form id="form"><label>Plexus base URL</label><input id="baseUrl" type="url" required value="' +
557    baseUrl +
558    '" placeholder="https://plexus.example">' +
559    '<label>Admin key</label><input id="adminKey" type="password" required value="' +
560    adminKey +
561    '">' +
562    '<button type="submit">Save</button></form><script>document.getElementById("form").addEventListener("submit",function(e){e.preventDefault();' +
563    'var data={plexusBaseUrl:document.getElementById("baseUrl").value,plexusAdminKey:document.getElementById("adminKey").value};' +
564    "var encoded=encodeURIComponent(JSON.stringify(data));var match=location.href.match(/[?&]return_to=([^&#]+)/);" +
565    'if(match){location.href=decodeURIComponent(match[1])+encoded;}else{location.href="pebblejs://close#"+encoded;}});</script></body></html>'
566  );
567}
568
569Pebble.addEventListener("ready", function () {
570  refreshQuota();
571});
572
573Pebble.addEventListener("appmessage", function (e) {
574  if (!e.payload) return;
575  if (e.payload.REQUEST_PAGE !== undefined) {
576    if (quotaGroups.length === 0) {
577      refreshQuota(e.payload.REQUEST_PAGE);
578      return;
579    }
580    sendGroupPage(e.payload.REQUEST_PAGE);
581    return;
582  }
583  if (e.payload.REQUEST_QUOTA) refreshQuota(e.payload.PAGE_INDEX);
584});
585
586Pebble.addEventListener("showConfiguration", function () {
587  Pebble.openURL("data:text/html," + encodeURIComponent(configurationHtml()));
588});
589
590Pebble.addEventListener("webviewclosed", function (e) {
591  if (!e || !e.response) return;
592  try {
593    var response = JSON.parse(decodeURIComponent(e.response));
594    localStorage.setItem(CONFIG_BASE_URL, trimSlash(response.plexusBaseUrl));
595    localStorage.setItem(CONFIG_ADMIN_KEY, response.plexusAdminKey || "");
596    refreshQuota();
597  } catch (error) {
598    sendError("Config save failed");
599  }
600});