Skip to content

Instantly share code, notes, and snippets.

@nickojs
Last active July 5, 2026 21:36
Show Gist options
  • Select an option

  • Save nickojs/1dccb26fe7bd343bdd8a64dc64a38298 to your computer and use it in GitHub Desktop.

Select an option

Save nickojs/1dccb26fe7bd343bdd8a64dc64a38298 to your computer and use it in GitHub Desktop.
(wip) claude usage greasemonkey script
// ==UserScript==
// @name Claude-O-Meter
// @namespace https://github.com/claude-o-meter
// @version 0.9.2
// @description A tiny, toggleable overlay on claude.ai showing your current session AND weekly usage, and when each resets. Free & open source — reads only the usage data your own logged-in session already has access to. No API keys, no scraping, no exfiltration.
// @author you
// @match https://claude.ai/*
// @icon https://claude.ai/favicon.ico
// @grant none
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// ---------- config ----------
const STORAGE_KEY = "claude-o-meter:open";
const DEBUG_KEY = "claude-o-meter:debug";
const SECTIONS_KEY = "claude-o-meter:sections";
const POS_KEY = "claude-o-meter:pos";
const AUTO_KEY = "claude-o-meter:auto";
const REFRESH_MS = 60_000;
const BTN_SIZE = 44; // keep in sync with .com-btn width/height
const EDGE_MARGIN = 8; // min gap between button/panel and the viewport edge
const DRAG_THRESHOLD = 4; // px of movement before a press counts as a drag
const LOG = "[Claude-O-Meter]";
// Debug logging. On by default while developing; flip DEBUG_DEFAULT to false
// for a "release", or toggle at runtime without editing this file via the
// console:
// localStorage.setItem("claude-o-meter:debug", "0") // off
// localStorage.setItem("claude-o-meter:debug", "1") // on
// localStorage.removeItem("claude-o-meter:debug") // back to default
const DEBUG_DEFAULT = false;
const stored = localStorage.getItem(DEBUG_KEY);
const DEBUG = stored === null ? DEBUG_DEFAULT : stored === "1";
function debug(...args) {
if (DEBUG) console.log(LOG, ...args);
}
// ---------- state ----------
let orgId = null;
// usage.metrics: { session: {pct, resetsAt}|null, weekly: {pct, resetsAt}|null }
let usage = { status: "loading", metrics: null, error: null };
let panelOpen = localStorage.getItem(STORAGE_KEY) === "1";
let autoRefresh = localStorage.getItem(AUTO_KEY) !== "0"; // default: on
let refreshTimer = null;
let button, ringFill, panel, panelBody, autoBtn;
// Which accordion sections are expanded, remembered across reloads.
// Missing key => expanded by default.
function loadSections() {
try {
return JSON.parse(localStorage.getItem(SECTIONS_KEY)) || {};
} catch (e) {
return {};
}
}
let sections = loadSections();
function isSectionExpanded(key) {
return sections[key] !== false; // default: expanded
}
function toggleSection(key) {
sections[key] = !isSectionExpanded(key);
localStorage.setItem(SECTIONS_KEY, JSON.stringify(sections));
render();
}
// Button position { left, top } in viewport px, remembered across reloads.
// Missing/invalid => null (falls back to the default bottom-right corner).
function loadPos() {
try {
const p = JSON.parse(localStorage.getItem(POS_KEY));
if (p && typeof p.left === "number" && typeof p.top === "number") return p;
} catch (e) {
/* ignore */
}
return null;
}
let pos = loadPos();
function savePos() {
if (pos) localStorage.setItem(POS_KEY, JSON.stringify(pos));
}
// ---------- org id lookup (cookie first, then the orgs endpoint) ----------
function orgIdFromCookie() {
const m = document.cookie.match(/lastActiveOrg=([0-9a-f-]{36})/i);
return m ? m[1] : null;
}
async function getOrgId() {
if (orgId) return orgId;
orgId = orgIdFromCookie();
if (orgId) {
debug("orgId from cookie:", orgId);
return orgId;
}
try {
const orgs = await fetch("https://claude.ai/api/organizations", {
credentials: "include",
headers: { Accept: "application/json" },
}).then((r) => r.json());
const first = Array.isArray(orgs) ? orgs[0] : orgs;
orgId = first?.uuid ?? first?.id ?? null;
debug("orgId from /api/organizations:", orgId);
} catch (e) {
debug("could not look up orgId:", e);
}
return orgId;
}
// ---------- data layer ----------
// Pull one metric out of the `limits` array by its `kind`, falling back to
// the top-level summary object the API also returns for the same window.
function metricFrom(data, kind, fallbackKey) {
const limit = Array.isArray(data?.limits)
? data.limits.find((l) => l && l.kind === kind)
: null;
if (limit && typeof limit.percent === "number") {
return { pct: limit.percent, resetsAt: limit.resets_at ?? null };
}
const fb = data?.[fallbackKey];
if (fb && typeof fb.utilization === "number") {
return { pct: fb.utilization, resetsAt: fb.resets_at ?? null };
}
return null;
}
function parseUsagePayload(data) {
// Guard the whole block: JSON.stringify is only worth running when debugging
// (arguments are evaluated before debug() can check the flag).
if (DEBUG) {
debug("raw usage response:", data);
debug("raw usage response as text:\n" + JSON.stringify(data, null, 2));
}
const metrics = {
session: metricFrom(data, "session", "five_hour"),
weekly: metricFrom(data, "weekly_all", "seven_day"),
};
return metrics.session || metrics.weekly ? metrics : null;
}
// Guard against overlapping requests: the 60s interval keeps firing even if
// a previous fetch is still in flight (slow network), so concurrent callers
// (interval, manual refresh, panel open) all share the one pending request.
let usageRequest = null;
function fetchUsage() {
if (!usageRequest) {
usageRequest = doFetchUsage().finally(() => {
usageRequest = null;
});
}
return usageRequest;
}
async function doFetchUsage() {
await getOrgId();
if (!orgId) {
usage = { status: "error", metrics: null, error: "Couldn't find org id" };
render();
return;
}
const url = `https://claude.ai/api/organizations/${orgId}/usage`;
try {
const res = await fetch(url, { credentials: "include", headers: { Accept: "application/json" } });
debug(`GET ${url} ->`, res.status);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const metrics = parseUsagePayload(data);
if (metrics) {
debug(
`session: ${metrics.session ? Math.round(metrics.session.pct) + "%" : "n/a"}, ` +
`weekly: ${metrics.weekly ? Math.round(metrics.weekly.pct) + "%" : "n/a"}`,
metrics
);
usage = { status: "ok", metrics, error: null };
} else {
debug('got a response but couldn\'t parse usage out of it — check the "raw usage response as text" above');
usage = { status: "error", metrics: null, error: "Unrecognized response shape" };
}
} catch (e) {
debug(`fetch failed for ${url}:`, e);
usage = { status: "error", metrics: null, error: "Usage data unavailable" };
}
render();
}
// ---------- formatting ----------
// Relative countdown, e.g. "resets in 3h 12m" — good for the short session window.
function formatResetsIn(resetsAt) {
if (!resetsAt) return null;
const target = new Date(resetsAt).getTime();
if (Number.isNaN(target)) return null;
const diffMs = target - Date.now();
if (diffMs <= 0) return "resets shortly";
const mins = Math.round(diffMs / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h <= 0) return `resets in ${m}m`;
return `resets in ${h}h ${m}m`;
}
// Absolute date/time in the user's locale, e.g. "resets Jul 12, 5:00 AM" —
// better for the weekly window, which is days out.
function formatResetsOn(resetsAt) {
if (!resetsAt) return null;
const d = new Date(resetsAt);
if (Number.isNaN(d.getTime())) return null;
return (
"resets " +
d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
);
}
function colorForPct(pct) {
if (pct == null) return "#9a9a9a";
if (pct < 60) return "#4caf7d";
if (pct < 85) return "#e0a83a";
return "#e05a4e";
}
// ---------- UI ----------
function injectStyles(shadow) {
const style = document.createElement("style");
style.textContent = `
:host { all: initial; }
.com-btn {
position: fixed;
/* left/top are set via JS (draggable); default corner applied on init */
width: 44px;
height: 44px;
border-radius: 50%;
z-index: 2147483000;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: var(--com-bg, #fff);
box-shadow: 0 4px 14px rgba(0,0,0,0.28);
border: none;
padding: 0;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.com-btn:hover { transform: scale(1.06); box-shadow: 0 6px 18px rgba(0,0,0,0.32); }
/* Lifted look while dragging — bigger, softer shadow reads as higher z. */
.com-btn.dragging {
cursor: grabbing;
transform: none;
box-shadow: 0 12px 28px rgba(0,0,0,0.4);
transition: box-shadow 0.15s ease;
}
.com-btn svg.ring { position: absolute; inset: -3px; width: 50px; height: 50px; }
.com-icon { width: 24px; height: 24px; position: relative; z-index: 1; }
.com-panel {
position: fixed;
/* left/top computed by positionPanel() relative to the button */
width: 220px;
border-radius: 14px;
z-index: 2147483000;
background: var(--com-bg, #fff);
color: var(--com-fg, #1a1a1a);
box-shadow: 0 8px 28px rgba(0,0,0,0.28);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
overflow: hidden;
display: none;
}
.com-panel.open { display: block; }
.com-panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
font-weight: 600;
border-bottom: 1px solid var(--com-border, rgba(0,0,0,0.08));
}
.com-panel-header .com-icon { width: 18px; height: 18px; }
.com-hbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--com-muted, #767676);
cursor: pointer;
}
.com-hbtn:hover { background: var(--com-track, rgba(0,0,0,0.08)); color: var(--com-fg, #1a1a1a); }
.com-hbtn svg { width: 15px; height: 15px; display: block; }
.com-refresh { margin-left: auto; } /* pushes the header button group right */
.com-refresh.spinning svg { animation: com-spin 0.8s linear infinite; }
.com-auto.active { color: #4caf7d; } /* green when auto-refresh is on */
.com-auto.active:hover { color: #3f9d6d; }
@keyframes com-spin { to { transform: rotate(360deg); } }
.com-panel-body { padding: 12px; }
.com-label { color: var(--com-muted, #767676); }
.com-pct { font-weight: 700; font-size: 15px; }
.com-bar-track { height: 6px; border-radius: 4px; background: var(--com-track, rgba(0,0,0,0.08)); overflow: hidden; margin: 8px 0; }
.com-bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s ease; }
.com-resets { color: var(--com-muted, #767676); font-size: 12px; }
.com-muted-pct { color: var(--com-muted, #767676); font-weight: 400; }
.com-divider { height: 1px; background: var(--com-border, rgba(0,0,0,0.08)); margin: 12px 0; }
.com-error { color: var(--com-muted, #767676); font-size: 12px; line-height: 1.4; }
/* accordion sections */
.com-section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 2px 0;
}
.com-head-right { display: flex; align-items: center; gap: 6px; }
.com-caret {
color: var(--com-muted, #767676);
font-size: 10px;
line-height: 1;
transition: transform 0.15s ease;
}
.com-section.expanded .com-caret { transform: rotate(90deg); }
.com-section-body { display: none; }
.com-section.expanded .com-section-body { display: block; }
@media (prefers-color-scheme: dark) {
.com-btn, .com-panel { --com-bg: #2b2b2b; --com-fg: #f0f0f0; --com-border: rgba(255,255,255,0.1); --com-muted: #a0a0a0; --com-track: rgba(255,255,255,0.12); }
}
`;
shadow.appendChild(style);
}
const ROBOT_SVG = `
<svg height="2em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="2em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>`;
const REFRESH_SVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12a9 9 0 1 1-2.64-6.36"/>
<path d="M21 3v6h-6"/>
</svg>`;
// Clock icon for the auto-refresh toggle; turns green (.active) when auto is on.
const CLOCK_SVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9"/>
<path d="M12 7v5l3 2"/>
</svg>`;
const RING_RADIUS = 22;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// The ring SVG is built once (in buildUI); updates only touch two attributes
// on the progress circle instead of re-parsing markup every refresh.
const RING_SVG = `
<svg class="ring" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="${RING_RADIUS}" fill="none" stroke="rgba(128,128,128,0.25)" stroke-width="3"/>
<circle class="ring-fill" cx="25" cy="25" r="${RING_RADIUS}" fill="none" stroke="#9a9a9a" stroke-width="3"
stroke-linecap="round"
stroke-dasharray="${RING_CIRCUMFERENCE}"
stroke-dashoffset="${RING_CIRCUMFERENCE}"
transform="rotate(-90 25 25)"/>
</svg>`;
// A collapsible section: clickable header (label + % + caret) and a body
// (colored bar + resets-in line) that shows/hides based on saved state.
function sectionHtml(key, label, metric) {
const expanded = isSectionExpanded(key);
const hasData = metric && typeof metric.pct === "number";
const pctText = hasData
? `<span class="com-pct">${Math.round(metric.pct)}%</span>`
: `<span class="com-pct com-muted-pct">–</span>`;
// Session shows a countdown; Weekly shows the actual reset date.
const resetsText = hasData
? key === "weekly"
? formatResetsOn(metric.resetsAt)
: formatResetsIn(metric.resetsAt)
: null;
const body = hasData
? `
<div class="com-bar-track">
<div class="com-bar-fill" style="width:${Math.min(100, metric.pct)}%;background:${colorForPct(metric.pct)}"></div>
</div>
${resetsText ? `<div class="com-resets">${resetsText}</div>` : ""}`
: `<div class="com-resets">No data</div>`;
return `
<div class="com-section ${expanded ? "expanded" : ""}" data-key="${key}">
<div class="com-section-header">
<span class="com-label">${label}</span>
<span class="com-head-right">
${pctText}
<span class="com-caret">▸</span>
</span>
</div>
<div class="com-section-body">${body}</div>
</div>`;
}
// The ring around the icon is always visible, so it always updates.
function renderRing() {
if (!ringFill) return;
const pct = usage.metrics?.session?.pct ?? null;
const clamped = Math.max(0, Math.min(100, pct ?? 0));
ringFill.setAttribute("stroke-dashoffset", String(RING_CIRCUMFERENCE * (1 - clamped / 100)));
ringFill.setAttribute("stroke", colorForPct(pct));
}
// The panel body (sections / loading / error) — only worth building when the
// panel is actually on screen. Returns true when the DOM actually changed:
// writing innerHTML dirties layout, and positionPanel() then reads offsets,
// which forces a synchronous reflow — skip both when the markup is identical.
let lastBodyHtml = null;
function renderBody() {
if (!panelBody) return false;
let html;
if (usage.status === "ok") {
html =
sectionHtml("session", "Current session", usage.metrics.session) +
`<div class="com-divider"></div>` +
sectionHtml("weekly", "Weekly", usage.metrics.weekly);
} else if (usage.status === "loading") {
html = `<div class="com-error">Loading usage…</div>`;
} else {
html = `<div class="com-error">${usage.error || "Usage data unavailable"}</div>`;
}
if (html === lastBodyHtml) return false;
lastBodyHtml = html;
panelBody.innerHTML = html;
return true;
}
function render() {
renderRing();
// Skip the (hidden) panel body entirely while closed; it's rebuilt on open.
if (!panelOpen) return;
if (renderBody()) {
positionPanel(); // height may have changed (expand/collapse, new data)
}
}
// ---------- positioning + dragging ----------
// Keep a left/top pair inside the viewport (with EDGE_MARGIN breathing room).
function clampButton(left, top) {
const maxLeft = window.innerWidth - BTN_SIZE - EDGE_MARGIN;
const maxTop = window.innerHeight - BTN_SIZE - EDGE_MARGIN;
return {
left: Math.max(EDGE_MARGIN, Math.min(left, maxLeft)),
top: Math.max(EDGE_MARGIN, Math.min(top, maxTop)),
};
}
// Apply the current (or default bottom-right) button position, clamped.
function applyButtonPos() {
if (!button) return;
if (!pos) {
pos = { left: window.innerWidth - 20 - BTN_SIZE, top: window.innerHeight - 20 - BTN_SIZE };
}
pos = clampButton(pos.left, pos.top);
button.style.left = pos.left + "px";
button.style.top = pos.top + "px";
}
// Position the panel next to the button, flipping direction near edges so it
// never clips off-screen.
function positionPanel() {
if (!panel || !panelOpen) return;
const gap = 10;
// Anchor to the button's LOGICAL box (pos + BTN_SIZE), not its rendered
// rect: getBoundingClientRect() includes the :hover scale transform, which
// would nudge the panel by ~1px depending on whether the button is hovered.
const b = pos || { left: window.innerWidth - 20 - BTN_SIZE, top: window.innerHeight - 20 - BTN_SIZE };
const btn = {
left: b.left,
top: b.top,
right: b.left + BTN_SIZE,
bottom: b.top + BTN_SIZE,
width: BTN_SIZE,
height: BTN_SIZE,
};
const pw = panel.offsetWidth || 220;
const ph = panel.offsetHeight || 0;
const vw = window.innerWidth;
const vh = window.innerHeight;
// Vertical: open below the button when it's in the top half, else above.
let top =
btn.top + btn.height / 2 < vh / 2 ? btn.bottom + gap : btn.top - gap - ph;
// Horizontal: right-align to the button when it's on the right half, else left-align.
let left = btn.left + btn.width / 2 > vw / 2 ? btn.right - pw : btn.left;
left = Math.max(EDGE_MARGIN, Math.min(left, vw - pw - EDGE_MARGIN));
top = Math.max(EDGE_MARGIN, Math.min(top, vh - ph - EDGE_MARGIN));
panel.style.left = left + "px";
panel.style.top = top + "px";
panel.style.right = "auto";
panel.style.bottom = "auto";
}
function togglePanel(force) {
panelOpen = typeof force === "boolean" ? force : !panelOpen;
panel.classList.toggle("open", panelOpen);
localStorage.setItem(STORAGE_KEY, panelOpen ? "1" : "0");
if (panelOpen) {
// Just show what we already have — opening the panel is a pure view
// action. Freshness is the auto-refresh loop's job (and the manual
// refresh button's); fetching here coupled data to open/close.
renderBody(); // paint latest known data now (closed polls skipped the body)
positionPanel();
}
}
// ---------- auto-refresh ----------
function startAuto() {
if (refreshTimer == null) refreshTimer = setInterval(fetchUsage, REFRESH_MS);
}
function stopAuto() {
if (refreshTimer != null) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function updateAutoButton() {
if (!autoBtn) return;
autoBtn.classList.toggle("active", autoRefresh);
autoBtn.title = autoRefresh
? `Auto-refresh on (every ${Math.round(REFRESH_MS / 1000)}s) — click to pause`
: "Auto-refresh paused — click to resume";
}
function setAutoRefresh(on) {
autoRefresh = on;
localStorage.setItem(AUTO_KEY, on ? "1" : "0");
if (on && !document.hidden) startAuto();
else stopAuto();
updateAutoButton();
}
function buildUI() {
const host = document.createElement("div");
host.id = "claude-o-meter-host";
document.documentElement.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
injectStyles(shadow);
button = document.createElement("button");
button.className = "com-btn";
button.title = "Claude usage (drag to move)";
button.innerHTML = `${RING_SVG}${ROBOT_SVG}`;
shadow.appendChild(button);
ringFill = button.querySelector(".ring-fill");
applyButtonPos();
// Drag to move; a press that doesn't move past DRAG_THRESHOLD counts as a
// click and toggles the panel. Pointer capture keeps the drag smooth.
let sx = 0, sy = 0, sLeft = 0, sTop = 0, dragging = false, dragRaf = 0;
// Batch position writes to one per frame so a fast pointer can't queue many
// layout-triggering style writes in a single frame.
function flushDragPos() {
dragRaf = 0;
button.style.left = pos.left + "px";
button.style.top = pos.top + "px";
}
button.addEventListener("pointerdown", (e) => {
if (e.button != null && e.button !== 0) return; // primary button only
e.preventDefault(); // stop the browser starting a page text-selection
dragging = false;
sx = e.clientX;
sy = e.clientY;
const r = button.getBoundingClientRect();
sLeft = r.left;
sTop = r.top;
button.setPointerCapture(e.pointerId);
});
button.addEventListener("pointermove", (e) => {
if (!button.hasPointerCapture(e.pointerId)) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
// Text selection during a drag is already prevented without touching the
// page: pointerdown calls preventDefault() and takes pointer capture, and
// the button itself is user-select: none. (Toggling user-select on
// document.documentElement — an inherited property on the root — forced a
// style recalc of claude.ai's entire DOM and was itself the drag hitch.)
if (!dragging && panelOpen) togglePanel(false); // close the drawer as the drag starts
dragging = true;
button.classList.add("dragging");
pos = clampButton(sLeft + dx, sTop + dy);
if (!dragRaf) dragRaf = requestAnimationFrame(flushDragPos);
});
function cleanupDrag(e) {
if (button.hasPointerCapture(e.pointerId)) button.releasePointerCapture(e.pointerId);
button.classList.remove("dragging");
if (dragRaf) {
cancelAnimationFrame(dragRaf);
dragRaf = 0;
}
}
button.addEventListener("pointerup", (e) => {
const wasDragging = dragging;
dragging = false;
cleanupDrag(e);
if (wasDragging) {
// Ensure the final position is applied even if a frame was pending.
button.style.left = pos.left + "px";
button.style.top = pos.top + "px";
savePos();
} else {
togglePanel();
}
});
button.addEventListener("pointercancel", (e) => {
dragging = false;
cleanupDrag(e);
});
panel = document.createElement("div");
panel.className = "com-panel" + (panelOpen ? " open" : "");
panel.innerHTML = `
<div class="com-panel-header">
${ROBOT_SVG}<span>Claude Usage</span>
<button class="com-hbtn com-refresh" title="Refresh now">${REFRESH_SVG}</button>
<button class="com-hbtn com-auto" title="Auto-refresh">${CLOCK_SVG}</button>
</div>
<div class="com-panel-body"></div>
`;
shadow.appendChild(panel);
panelBody = panel.querySelector(".com-panel-body");
// Manual refresh — re-calls the usage endpoint and spins while in flight.
const refreshBtn = panel.querySelector(".com-refresh");
refreshBtn.addEventListener("click", async () => {
refreshBtn.classList.add("spinning");
try {
await fetchUsage();
} finally {
refreshBtn.classList.remove("spinning");
}
});
// Auto-refresh toggle — pause/resume the background polling.
autoBtn = panel.querySelector(".com-auto");
autoBtn.addEventListener("click", () => setAutoRefresh(!autoRefresh));
updateAutoButton();
// Delegated so it keeps working after render() replaces the inner HTML.
panelBody.addEventListener("click", (e) => {
const header = e.target.closest(".com-section-header");
const section = header && header.closest(".com-section");
if (section && section.dataset.key) toggleSection(section.dataset.key);
});
render();
}
function init() {
// The icon must show no matter what happens below — build it first,
// on its own, before touching anything related to fetching data.
try {
buildUI();
} catch (e) {
console.error(`${LOG} failed to build UI:`, e);
return;
}
try {
fetchUsage();
if (autoRefresh && !document.hidden) startAuto();
// Pause polling while the tab is hidden; resume + one refresh on return.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopAuto();
} else if (autoRefresh) {
fetchUsage();
startAuto();
}
});
// Coalesce resize bursts into a single layout pass per frame.
let resizeRaf = 0;
window.addEventListener("resize", () => {
if (resizeRaf) return;
resizeRaf = requestAnimationFrame(() => {
resizeRaf = 0;
applyButtonPos(); // re-clamp so a shrunk window can't strand the button
if (panelOpen) positionPanel();
});
});
} catch (e) {
console.error(`${LOG} data layer failed to start (icon still shown):`, e);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment