Skip to content

Instantly share code, notes, and snippets.

@wolph
Last active August 4, 2026 22:43
Show Gist options
  • Select an option

  • Save wolph/6603bbad22549a6deca396350e485554 to your computer and use it in GitHub Desktop.

Select an option

Save wolph/6603bbad22549a6deca396350e485554 to your computer and use it in GitHub Desktop.
Claude Usage Pace userscript
// ==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