Last active
July 5, 2026 21:36
-
-
Save nickojs/1dccb26fe7bd343bdd8a64dc64a38298 to your computer and use it in GitHub Desktop.
(wip) claude usage greasemonkey script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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