Last active
August 4, 2026 22:43
-
-
Save wolph/6603bbad22549a6deca396350e485554 to your computer and use it in GitHub Desktop.
Claude Usage Pace userscript
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 Usage Pace | |
| // @namespace https://claude.ai/ | |
| // @version 2.2.0 | |
| // @description Show whether Claude plan usage is ahead of or behind a linear limit pace. | |
| // @author Codex | |
| // @license MIT | |
| // @match https://claude.ai/* | |
| // @include https://claude.ai/settings* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| const VERSION = "2.2.0"; | |
| const MINUTE = 1; | |
| const HOUR = 60 * MINUTE; | |
| const DAY = 24 * HOUR; | |
| const CONFIG = { | |
| // Ordered rules: first match wins. Matched against the meter's own label. | |
| labelWindowRules: [ | |
| { pattern: /\bsessions?\b/i, windowMinutes: 5 * HOUR }, | |
| { pattern: /\bweek(ly)?\b/i, windowMinutes: 7 * DAY }, | |
| { pattern: /\bmonth(ly)?\b/i, windowMinutes: 30 * DAY }, | |
| { pattern: /\bdaily\b|\bper day\b/i, windowMinutes: 1 * DAY }, | |
| ], | |
| // Matched against surrounding section headings (h1-h4). | |
| headingWindowRules: [ | |
| { pattern: /\bweek(ly)?\b/i, windowMinutes: 7 * DAY }, | |
| { pattern: /\bsessions?\b/i, windowMinutes: 5 * HOUR }, | |
| { pattern: /\bdaily\b/i, windowMinutes: 1 * DAY }, | |
| { pattern: /\bmonth(ly)?\b/i, windowMinutes: 30 * DAY }, | |
| ], | |
| // Last-resort inference: smallest plausible window >= time remaining. | |
| windowCandidates: [5 * HOUR, 1 * DAY, 7 * DAY, 30 * DAY], | |
| thresholds: { | |
| onPacePp: 0.5, | |
| warningAheadPp: 5, | |
| hardCapPercent: 100, | |
| }, | |
| colors: { | |
| under: { | |
| fill: "#2f8f5b", | |
| text: "#26734a", | |
| marker: "#1f5f3d", | |
| }, | |
| near: { | |
| fill: "#c98712", | |
| text: "#9a6508", | |
| marker: "#8a5b07", | |
| }, | |
| over: { | |
| fill: "#d14b32", | |
| text: "#b73d28", | |
| marker: "#8f2f1f", | |
| }, | |
| unknown: { | |
| fill: "", | |
| text: "currentColor", | |
| marker: "#555555", | |
| }, | |
| }, | |
| }; | |
| const STYLE_ID = "claude-usage-pace-style"; | |
| // Claude has used role="progressbar" and role="meter" over time; support both, | |
| // plus <progress> and the design-system wrapper, so markup churn is survivable. | |
| const METER_SELECTOR = [ | |
| '[role="meter"]', | |
| '[role="progressbar"]', | |
| "progress", | |
| '[data-cds="Meter"] > [aria-valuenow]', | |
| ].join(","); | |
| const TEXT_SELECTOR = "span,p,div,h1,h2,h3,h4,a,button,label,dt,dd,li,td,th"; | |
| const HEADING_SELECTOR = "h1,h2,h3,h4,[role='heading']"; | |
| const RESET_RE = /\bresets?\b/i; | |
| const WEEKDAYS = { | |
| sun: 0, | |
| sunday: 0, | |
| mon: 1, | |
| monday: 1, | |
| tue: 2, | |
| tues: 2, | |
| tuesday: 2, | |
| wed: 3, | |
| weds: 3, | |
| wednesday: 3, | |
| thu: 4, | |
| thur: 4, | |
| thurs: 4, | |
| thursday: 4, | |
| fri: 5, | |
| friday: 5, | |
| sat: 6, | |
| saturday: 6, | |
| }; | |
| const MONTHS = { | |
| jan: 0, | |
| feb: 1, | |
| mar: 2, | |
| apr: 3, | |
| may: 4, | |
| jun: 5, | |
| jul: 6, | |
| aug: 7, | |
| sep: 8, | |
| sept: 8, | |
| oct: 9, | |
| nov: 10, | |
| dec: 11, | |
| }; | |
| function clamp(value, min, max) { | |
| return Math.min(max, Math.max(min, value)); | |
| } | |
| function roundToOne(value) { | |
| return Math.round(value * 10) / 10; | |
| } | |
| function normalizeText(element) { | |
| return (element && element.textContent ? element.textContent : "") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| function isOurNode(element) { | |
| return Boolean( | |
| element && | |
| element.dataset && | |
| (element.dataset.cupMarker === "true" || element.dataset.cupLabel === "true"), | |
| ); | |
| } | |
| // Settings can render as a full page *or* as a modal on top of any route, | |
| // so do not gate on the path. Host check only; the meter scan is the filter. | |
| function shouldRunForLocation(locationLike) { | |
| if (!locationLike) { | |
| return false; | |
| } | |
| return /(^|\.)claude\.ai$/.test(locationLike.hostname || ""); | |
| } | |
| function to24Hour(hour, meridiem) { | |
| let result = Number(hour); | |
| if (!Number.isFinite(result)) { | |
| return null; | |
| } | |
| if (!meridiem) { | |
| return result; | |
| } | |
| const lower = meridiem.toLowerCase(); | |
| if (lower === "am" && result === 12) { | |
| return 0; | |
| } | |
| if (lower === "pm" && result !== 12) { | |
| return result + 12; | |
| } | |
| return result; | |
| } | |
| function minutesUntil(target, now) { | |
| return Math.max(0, Math.ceil((target.getTime() - now.getTime()) / 60000)); | |
| } | |
| // "Resets Mon 5:00 AM" -> next weekday occurrence, weekly window. | |
| function parseWeekdayReset(text, now) { | |
| const match = text.match( | |
| /\bresets?\s+(?:on\s+)?(sun|sunday|mon|monday|tue|tues|tuesday|wed|weds|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday)\b(?:\s+at)?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?/i, | |
| ); | |
| if (!match) { | |
| return null; | |
| } | |
| const targetDay = WEEKDAYS[match[1].toLowerCase()]; | |
| if (!Number.isFinite(targetDay)) { | |
| return null; | |
| } | |
| const hour = match[2] === undefined ? 0 : to24Hour(match[2], match[4]); | |
| const minute = Number(match[3] || 0); | |
| if (!Number.isFinite(hour) || !Number.isFinite(minute)) { | |
| return null; | |
| } | |
| const target = new Date(now.getTime()); | |
| target.setDate(now.getDate() + ((targetDay - now.getDay() + 7) % 7)); | |
| target.setHours(hour, minute, 0, 0); | |
| if (target <= now) { | |
| target.setDate(target.getDate() + 7); | |
| } | |
| return { | |
| kind: "weekday", | |
| remainingMinutes: minutesUntil(target, now), | |
| windowMinutes: 7 * DAY, | |
| }; | |
| } | |
| // "Resets Sep 1" / "Resets Sep 1, 2026 at 5:00 PM" -> calendar-period window. | |
| function parseDateReset(text, now) { | |
| const match = text.match( | |
| /\bresets?\s+(?:on\s+)?(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+(\d{1,2})(?:,?\s*(\d{4}))?(?:\s+at)?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?/i, | |
| ); | |
| if (!match) { | |
| return null; | |
| } | |
| const month = MONTHS[match[1].toLowerCase()]; | |
| const day = Number(match[2]); | |
| const explicitYear = match[3] ? Number(match[3]) : null; | |
| const hour = match[4] === undefined ? 0 : to24Hour(match[4], match[6]); | |
| const minute = Number(match[5] || 0); | |
| if (!Number.isFinite(month) || !Number.isFinite(day) || !Number.isFinite(hour)) { | |
| return null; | |
| } | |
| const target = new Date( | |
| explicitYear === null ? now.getFullYear() : explicitYear, | |
| month, | |
| day, | |
| hour, | |
| minute, | |
| 0, | |
| 0, | |
| ); | |
| if (explicitYear === null && target <= now) { | |
| target.setFullYear(target.getFullYear() + 1); | |
| } | |
| const previous = new Date(target.getTime()); | |
| previous.setMonth(previous.getMonth() - 1); | |
| const windowMinutes = Math.round((target.getTime() - previous.getTime()) / 60000); | |
| return { | |
| kind: "date", | |
| remainingMinutes: minutesUntil(target, now), | |
| windowMinutes: windowMinutes > 0 ? windowMinutes : null, | |
| }; | |
| } | |
| // "Resets in 1 hr 28 min" -> relative duration, window unknown. | |
| function parseDurationReset(text) { | |
| let minutes = 0; | |
| let matched = false; | |
| const units = [ | |
| [/\b(\d+)\s*(?:weeks?|wks?|w)\b/g, 7 * DAY], | |
| [/\b(\d+)\s*(?:days?|d)\b/g, DAY], | |
| [/\b(\d+)\s*(?:hours?|hrs?|hr|h)\b/g, HOUR], | |
| [/\b(\d+)\s*(?:minutes?|mins?|min|m)\b/g, MINUTE], | |
| [/\b(\d+)\s*(?:seconds?|secs?|sec|s)\b/g, 0], | |
| ]; | |
| for (const [regex, multiplier] of units) { | |
| let match = regex.exec(text); | |
| while (match) { | |
| matched = true; | |
| minutes += Number(match[1]) * multiplier; | |
| match = regex.exec(text); | |
| } | |
| } | |
| if (!matched) { | |
| return null; | |
| } | |
| return { | |
| kind: "duration", | |
| remainingMinutes: Math.max(0, Math.round(minutes)), | |
| windowMinutes: null, | |
| }; | |
| } | |
| function parseReset(value, now = new Date()) { | |
| if (typeof value !== "string" || !value.trim()) { | |
| return null; | |
| } | |
| const text = value.toLowerCase(); | |
| return ( | |
| parseWeekdayReset(text, now) || | |
| parseDateReset(text, now) || | |
| parseDurationReset(text) || | |
| null | |
| ); | |
| } | |
| // Kept for backwards compatibility with existing tests/consumers. | |
| function parseResetMinutes(value, now = new Date()) { | |
| const parsed = parseReset(value, now); | |
| return parsed ? parsed.remainingMinutes : null; | |
| } | |
| function matchRules(rules, candidates) { | |
| for (const rule of rules) { | |
| for (const candidate of candidates) { | |
| if (candidate && rule.pattern.test(candidate)) { | |
| return rule.windowMinutes; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function inferWindowFromRemaining(remainingMinutes) { | |
| if (!Number.isFinite(remainingMinutes)) { | |
| return null; | |
| } | |
| return ( | |
| CONFIG.windowCandidates.find((candidate) => candidate >= remainingMinutes) || null | |
| ); | |
| } | |
| function resolveWindowMinutes(reset, label, headings = []) { | |
| if (!reset) { | |
| return null; | |
| } | |
| if (Number.isFinite(reset.windowMinutes) && reset.windowMinutes > 0) { | |
| return reset.windowMinutes; | |
| } | |
| return ( | |
| matchRules(CONFIG.labelWindowRules, [label]) || | |
| matchRules(CONFIG.headingWindowRules, headings) || | |
| inferWindowFromRemaining(reset.remainingMinutes) | |
| ); | |
| } | |
| function calculateExpectedPercent(windowMinutes, remainingMinutes) { | |
| if ( | |
| !Number.isFinite(windowMinutes) || | |
| !Number.isFinite(remainingMinutes) || | |
| windowMinutes <= 0 | |
| ) { | |
| return null; | |
| } | |
| const elapsedMinutes = clamp(windowMinutes - remainingMinutes, 0, windowMinutes); | |
| return roundToOne((elapsedMinutes / windowMinutes) * 100); | |
| } | |
| function classifyUsage(usedPercent, expectedPercent, thresholds = CONFIG.thresholds) { | |
| if (!Number.isFinite(usedPercent) || !Number.isFinite(expectedPercent)) { | |
| return { tone: "unknown", delta: null }; | |
| } | |
| const delta = roundToOne(usedPercent - expectedPercent); | |
| if (usedPercent >= thresholds.hardCapPercent) { | |
| return { tone: "over", delta }; | |
| } | |
| if (delta <= thresholds.onPacePp) { | |
| return { tone: "under", delta }; | |
| } | |
| if (delta <= thresholds.warningAheadPp) { | |
| return { tone: "near", delta }; | |
| } | |
| return { tone: "over", delta }; | |
| } | |
| function formatDeltaLabel(usedPercent, expectedPercent) { | |
| if (!Number.isFinite(usedPercent) || !Number.isFinite(expectedPercent)) { | |
| return "pace unknown"; | |
| } | |
| const expected = Math.round(expectedPercent); | |
| const delta = usedPercent - expectedPercent; | |
| if (Math.abs(delta) <= CONFIG.thresholds.onPacePp) { | |
| return `pace ${expected}% · on pace`; | |
| } | |
| const direction = delta > 0 ? "ahead" : "behind"; | |
| const points = Math.round(Math.abs(delta)); | |
| const unit = points === 1 ? "point" : "points"; | |
| return `pace ${expected}% · ${points} ${unit} ${direction}`; | |
| } | |
| function ensureStyles(documentRef) { | |
| if (documentRef.getElementById(STYLE_ID)) { | |
| return; | |
| } | |
| const style = documentRef.createElement("style"); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| [data-cup-track-wrapper="true"] { | |
| position: relative !important; | |
| } | |
| [data-cup-marker="true"] { | |
| position: absolute !important; | |
| display: block !important; | |
| visibility: visible !important; | |
| opacity: 1 !important; | |
| top: 50%; | |
| height: 14px; | |
| width: 2px; | |
| margin-top: -7px; | |
| border-radius: 999px; | |
| box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 0 2px rgba(0, 0, 0, 0.15); | |
| pointer-events: none; | |
| z-index: 3; | |
| } | |
| [data-cup-label="true"] { | |
| display: block !important; | |
| visibility: visible !important; | |
| opacity: 1 !important; | |
| margin-top: 4px; | |
| font-size: 11px; | |
| line-height: 1.25; | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| `; | |
| (documentRef.head || documentRef.documentElement).appendChild(style); | |
| } | |
| function getTexts(container, selector = TEXT_SELECTOR) { | |
| if (!container || !container.querySelectorAll) { | |
| return []; | |
| } | |
| return Array.from(container.querySelectorAll(selector)) | |
| .filter((element) => !isOurNode(element) && !element.closest("[data-cup-label]")) | |
| .map(normalizeText) | |
| .filter((text) => text && text.length <= 180); | |
| } | |
| function countMeters(node) { | |
| return node.querySelectorAll(METER_SELECTOR).length; | |
| } | |
| function getResetText(texts) { | |
| // Prefer the shortest matching string: that is the leaf node holding just | |
| // the reset copy, not a parent that swallowed the whole section. | |
| return ( | |
| texts | |
| .filter((text) => RESET_RE.test(text)) | |
| .sort((a, b) => a.length - b.length)[0] || null | |
| ); | |
| } | |
| // The "row" is the smallest ancestor holding exactly one meter plus its | |
| // reset copy. Independent of class names, so restyles do not break it. | |
| function findMeterRow(meter) { | |
| let node = meter.parentElement; | |
| let fallback = null; | |
| while (node && node !== document.body) { | |
| if (countMeters(node) === 1) { | |
| const texts = getTexts(node); | |
| if (getResetText(texts)) { | |
| return node; | |
| } | |
| fallback = node; | |
| } else if (countMeters(node) > 1) { | |
| break; | |
| } | |
| node = node.parentElement; | |
| } | |
| return fallback; | |
| } | |
| function getAncestorHeadings(node, limit = 6) { | |
| const headings = []; | |
| let current = node; | |
| let depth = 0; | |
| while (current && current !== document.body && depth < limit) { | |
| for (const text of getTexts(current, HEADING_SELECTOR)) { | |
| if (!headings.includes(text)) { | |
| headings.push(text); | |
| } | |
| } | |
| current = current.parentElement; | |
| depth += 1; | |
| } | |
| return headings; | |
| } | |
| function getMeterLabel(meter, row) { | |
| const labelledBy = meter.getAttribute("aria-labelledby"); | |
| if (labelledBy) { | |
| const parts = labelledBy | |
| .split(/\s+/) | |
| .map((id) => meter.ownerDocument.getElementById(id)) | |
| .filter(Boolean) | |
| .map(normalizeText) | |
| .filter(Boolean); | |
| if (parts.length) { | |
| return parts.join(" "); | |
| } | |
| } | |
| const ariaLabel = meter.getAttribute("aria-label"); | |
| if (ariaLabel) { | |
| return ariaLabel.trim(); | |
| } | |
| const rowTexts = getTexts(row); | |
| return ( | |
| rowTexts.find( | |
| (text) => !RESET_RE.test(text) && !/%/.test(text) && text.length <= 60, | |
| ) || "" | |
| ); | |
| } | |
| function numAttr(element, name, fallback = NaN) { | |
| const raw = element.getAttribute(name); | |
| if (raw === null || raw.trim() === "") { | |
| return fallback; | |
| } | |
| const value = Number(raw); | |
| return Number.isFinite(value) ? value : fallback; | |
| } | |
| function getUsedPercent(meter, row) { | |
| const now = numAttr(meter, "aria-valuenow", numAttr(meter, "value")); | |
| if (Number.isFinite(now)) { | |
| const min = numAttr(meter, "aria-valuemin", 0); | |
| const max = numAttr(meter, "aria-valuemax", numAttr(meter, "max", 100)); | |
| if (max > min) { | |
| return roundToOne(((now - min) / (max - min)) * 100); | |
| } | |
| return roundToOne(now); | |
| } | |
| const candidates = [meter.getAttribute("aria-valuetext") || ""].concat(getTexts(row)); | |
| for (const text of candidates) { | |
| const match = text.match(/(\d+(?:[.,]\d+)?)\s*%/); | |
| if (match) { | |
| return roundToOne(Number(match[1].replace(",", "."))); | |
| } | |
| } | |
| return NaN; | |
| } | |
| function getProgressFill(meter) { | |
| return ( | |
| Array.from(meter.children).find( | |
| (child) => !isOurNode(child) && child.nodeType === 1, | |
| ) || null | |
| ); | |
| } | |
| function getOrCreateNode(wrapper, flag, tagName) { | |
| let node = Array.from(wrapper.children).find( | |
| (child) => child.dataset && child.dataset[flag] === "true", | |
| ); | |
| if (!node) { | |
| node = wrapper.ownerDocument.createElement(tagName); | |
| node.dataset[flag] = "true"; | |
| wrapper.appendChild(node); | |
| } | |
| return node; | |
| } | |
| function removeDecorations(meter) { | |
| const wrapper = meter.parentElement; | |
| if (!wrapper) { | |
| return; | |
| } | |
| Array.from(wrapper.children) | |
| .filter(isOurNode) | |
| .forEach((node) => node.remove()); | |
| } | |
| function setIfChanged(element, property, value) { | |
| if (element.style[property] !== value) { | |
| element.style[property] = value; | |
| } | |
| } | |
| function skip(meter) { | |
| removeDecorations(meter); | |
| delete meter.dataset.cupTone; | |
| delete meter.dataset.cupProgress; | |
| return false; | |
| } | |
| function enhanceMeter(meter) { | |
| const row = findMeterRow(meter); | |
| if (!row) { | |
| return skip(meter); | |
| } | |
| const reset = parseReset(getResetText(getTexts(row))); | |
| if (!reset) { | |
| return skip(meter); | |
| } | |
| const label = getMeterLabel(meter, row); | |
| const windowMinutes = resolveWindowMinutes(reset, label, getAncestorHeadings(row)); | |
| const expectedPercent = calculateExpectedPercent(windowMinutes, reset.remainingMinutes); | |
| if (!Number.isFinite(expectedPercent)) { | |
| return skip(meter); | |
| } | |
| const usedPercent = getUsedPercent(meter, row); | |
| if (!Number.isFinite(usedPercent)) { | |
| return skip(meter); | |
| } | |
| const wrapper = meter.parentElement; | |
| if (!wrapper) { | |
| return skip(meter); | |
| } | |
| const classification = classifyUsage(usedPercent, expectedPercent); | |
| const color = CONFIG.colors[classification.tone] || CONFIG.colors.unknown; | |
| wrapper.dataset.cupTrackWrapper = "true"; | |
| meter.dataset.cupProgress = "true"; | |
| meter.dataset.cupTone = classification.tone; | |
| const fill = getProgressFill(meter); | |
| if (fill && color.fill && fill.style.getPropertyValue("background-color") !== color.fill) { | |
| fill.style.setProperty("background-color", color.fill, "important"); | |
| } | |
| const marker = getOrCreateNode(wrapper, "cupMarker", "div"); | |
| setIfChanged(marker, "display", "block"); | |
| setIfChanged(marker, "left", `calc(${clamp(expectedPercent, 0, 100)}% - 1px)`); | |
| setIfChanged(marker, "backgroundColor", color.marker); | |
| marker.title = `expected ${Math.round(expectedPercent)}% at this point in the window`; | |
| const labelNode = getOrCreateNode(wrapper, "cupLabel", "span"); | |
| const labelText = formatDeltaLabel(usedPercent, expectedPercent); | |
| labelNode.dataset.cupTone = classification.tone; | |
| setIfChanged(labelNode, "color", color.text); | |
| if (labelNode.textContent !== labelText) { | |
| labelNode.textContent = labelText; | |
| } | |
| return true; | |
| } | |
| function enhanceAll() { | |
| if (typeof window !== "undefined" && !shouldRunForLocation(window.location)) { | |
| return 0; | |
| } | |
| const meters = Array.from(document.querySelectorAll(METER_SELECTOR)).filter( | |
| (meter) => meter.parentElement && !isOurNode(meter), | |
| ); | |
| if (!meters.length) { | |
| return 0; | |
| } | |
| ensureStyles(document); | |
| let enhanced = 0; | |
| for (const meter of meters) { | |
| try { | |
| if (enhanceMeter(meter)) { | |
| enhanced += 1; | |
| } | |
| } catch (error) { | |
| // Never let one odd node break the rest of the page. | |
| console.debug("[claude-usage-pace]", error); | |
| } | |
| } | |
| return enhanced; | |
| } | |
| function installRouteFallbacks(schedule) { | |
| if (typeof window === "undefined") { | |
| return; | |
| } | |
| const wrapHistoryMethod = (methodName) => { | |
| const original = window.history && window.history[methodName]; | |
| if (typeof original !== "function" || original.__cupWrapped) { | |
| return; | |
| } | |
| const wrapped = function (...args) { | |
| const result = original.apply(this, args); | |
| schedule(); | |
| return result; | |
| }; | |
| wrapped.__cupWrapped = true; | |
| window.history[methodName] = wrapped; | |
| }; | |
| wrapHistoryMethod("pushState"); | |
| wrapHistoryMethod("replaceState"); | |
| window.addEventListener("popstate", schedule); | |
| window.addEventListener("hashchange", schedule); | |
| document.addEventListener("visibilitychange", schedule); | |
| document.addEventListener("click", schedule, true); | |
| // Countdown text ticks down, so keep recomputing while the tab is open. | |
| setInterval(schedule, 30000); | |
| } | |
| function init() { | |
| if (typeof document === "undefined") { | |
| return; | |
| } | |
| // Exposed in both worlds so the API stays reachable from the console | |
| // whether the script runs sandboxed or in the page. | |
| if (typeof window !== "undefined") { | |
| window.__claudeUsagePace = api; | |
| } | |
| try { | |
| if (typeof unsafeWindow !== "undefined" && unsafeWindow) { | |
| unsafeWindow.__claudeUsagePace = api; | |
| } | |
| } catch (error) { | |
| /* cross-world assignment blocked; ignore */ | |
| } | |
| let timer = null; | |
| const schedule = () => { | |
| if (timer) { | |
| clearTimeout(timer); | |
| } | |
| timer = setTimeout(() => { | |
| timer = null; | |
| enhanceAll(); | |
| }, 200); | |
| }; | |
| enhanceAll(); | |
| installRouteFallbacks(schedule); | |
| const observer = new MutationObserver((mutations) => { | |
| // Ignore mutations we caused ourselves to avoid feedback loops. | |
| const relevant = mutations.some((mutation) => { | |
| const target = mutation.target; | |
| const element = target && target.nodeType === 1 ? target : target && target.parentElement; | |
| return !element || !(isOurNode(element) || element.closest("[data-cup-label]")); | |
| }); | |
| if (relevant) { | |
| schedule(); | |
| } | |
| }); | |
| observer.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| } | |
| const api = { | |
| VERSION, | |
| CONFIG, | |
| parseReset, | |
| parseResetMinutes, | |
| resolveWindowMinutes, | |
| inferWindowFromRemaining, | |
| calculateExpectedPercent, | |
| classifyUsage, | |
| formatDeltaLabel, | |
| shouldRunForLocation, | |
| getResetText, | |
| enhanceAll, | |
| }; | |
| if (typeof module === "object" && module.exports) { | |
| module.exports = { | |
| ClaudeUsagePace: api, | |
| }; | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment