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, "&")
542 .replace(/</g, "<")
543 .replace(/>/g, ">")
544 .replace(/"/g, """);
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});