Show whether Claude plan usage is ahead of or behind a linear limit pace.
// ==UserScript==
// @name Claude Usage Pace
// @namespace https://claude.ai/
// @version 1.0.1
// @description Show whether Claude plan usage is ahead of or behind a linear limit pace.
// @author Codex
// @license MIT
// @match https://claude.ai/settings/usage*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
limits: {
"Current session": {
windowMinutes: 5 * 60,
},
"All models": {
windowMinutes: 7 * 24 * 60,
fallbackGroupHeading: "Weekly limits",
},
"Sonnet only": {
windowMinutes: 7 * 24 * 60,
fallbackGroupHeading: "Weekly limits",
},
"Claude Design": {
windowMinutes: 7 * 24 * 60,
fallbackGroupHeading: "Weekly limits",
},
},
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";
const TEXT_SELECTOR = [
"span",
"p",
"div",
"h1",
"h2",
"h3",
"h4",
"a",
"button",
].join(",");
const WEEKDAYS = {
sun: 0,
sunday: 0,
mon: 1,
monday: 1,
tue: 2,
tuesday: 2,
wed: 3,
wednesday: 3,
thu: 4,
thursday: 4,
fri: 5,
friday: 5,
sat: 6,
saturday: 6,
};
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 parseWeekdayResetMinutes(text, now) {
const match = text.match(
/\bresets\s+(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i,
);
if (!match) {
return null;
}
const targetDay = WEEKDAYS[match[1].toLowerCase()];
let hour = Number(match[2]);
const minute = Number(match[3] || 0);
const meridiem = match[4].toLowerCase();
if (!Number.isFinite(targetDay) || !Number.isFinite(hour) || !Number.isFinite(minute)) {
return null;
}
if (meridiem === "am" && hour === 12) {
hour = 0;
} else if (meridiem === "pm" && hour !== 12) {
hour += 12;
}
const target = new Date(now.getTime());
const daysUntilTarget = (targetDay - now.getDay() + 7) % 7;
target.setDate(now.getDate() + daysUntilTarget);
target.setHours(hour, minute, 0, 0);
if (target <= now) {
target.setDate(target.getDate() + 7);
}
return Math.max(0, Math.ceil((target.getTime() - now.getTime()) / 60000));
}
function parseResetMinutes(value, now = new Date()) {
if (typeof value !== "string") {
return null;
}
const text = value.toLowerCase();
const weekdayMinutes = parseWeekdayResetMinutes(text, now);
if (weekdayMinutes !== null) {
return weekdayMinutes;
}
let minutes = 0;
let matched = false;
const units = [
[/\b(\d+)\s*(?:days?|d)\b/g, 24 * 60],
[/\b(\d+)\s*(?:hours?|hrs?|hr|h)\b/g, 60],
[/\b(\d+)\s*(?:minutes?|mins?|min)\b/g, 1],
];
for (const [regex, multiplier] of units) {
let match = regex.exec(text);
while (match) {
matched = true;
minutes += Number(match[1]) * multiplier;
match = regex.exec(text);
}
}
return matched ? minutes : null;
}
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 getLimitConfig(label) {
return CONFIG.limits[label] || null;
}
function getResetText(texts) {
return texts.find((text) => /^resets\b/i.test(text)) || null;
}
function getLimitLabelsFromTexts(texts) {
return Object.keys(CONFIG.limits).filter((label) => texts.includes(label));
}
function choosePlanLabel(texts, progressCount) {
if (progressCount !== 1) {
return null;
}
const labels = getLimitLabelsFromTexts(texts);
return labels.length === 1 ? labels[0] : null;
}
function chooseResetText(label, rowTexts, fallbackGroupTexts = []) {
const directReset = getResetText(rowTexts);
if (directReset) {
return directReset;
}
const config = getLimitConfig(label);
if (!config || !config.fallbackGroupHeading) {
return null;
}
return getResetText(fallbackGroupTexts);
}
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";
return `pace ${expected}% · ${Math.round(Math.abs(delta))}pp ${direction}`;
}
function ensureStyles(documentRef) {
if (documentRef.getElementById(STYLE_ID)) {
return;
}
const style = documentRef.createElement("style");
style.id = STYLE_ID;
style.textContent = `
[data-cup-progress="true"] {
overflow: hidden;
}
[data-cup-track-wrapper="true"] {
position: relative !important;
}
[data-cup-marker="true"] {
position: absolute;
top: -3px;
height: 14px;
width: 2px;
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;
margin-top: 4px;
font-size: 11px;
line-height: 1.25;
font-weight: 500;
white-space: nowrap;
}
`;
documentRef.head.appendChild(style);
}
function getTexts(container, selector = TEXT_SELECTOR) {
return Array.from(container.querySelectorAll(selector))
.map(normalizeText)
.filter((text) => text && text.length <= 180);
}
function findPlanRow(progressBar) {
let node = progressBar.parentElement;
while (node && node !== document.body) {
const progressCount = node.querySelectorAll('[role="progressbar"]').length;
const label = choosePlanLabel(getTexts(node), progressCount);
if (label) {
return {
row: node,
label,
};
}
node = node.parentElement;
}
return null;
}
function findGroupByHeading(row, heading) {
let node = row.parentElement;
while (node && node !== document.body) {
if (getTexts(node, "h1,h2,h3,h4").includes(heading)) {
return node;
}
node = node.parentElement;
}
return null;
}
function findResetText(label, row) {
const config = getLimitConfig(label);
const fallbackGroup = config && config.fallbackGroupHeading
? findGroupByHeading(row, config.fallbackGroupHeading)
: null;
return chooseResetText(
label,
getTexts(row),
fallbackGroup ? getTexts(fallbackGroup) : [],
);
}
function getProgressFill(progressBar) {
return (
Array.from(progressBar.children).find(
(child) => child.dataset.cupMarker !== "true" && child.style.width,
) || null
);
}
function getOrCreateMarker(progressBar) {
const wrapper = progressBar.parentElement;
wrapper.dataset.cupTrackWrapper = "true";
let marker = Array.from(wrapper.children).find(
(child) => child.dataset.cupMarker === "true",
);
if (!marker) {
marker = progressBar.ownerDocument.createElement("div");
marker.dataset.cupMarker = "true";
wrapper.appendChild(marker);
}
return marker;
}
function getOrCreateLabel(progressBar) {
const wrapper = progressBar.parentElement;
let label = Array.from(wrapper.children).find((child) => child.dataset.cupLabel === "true");
if (!label) {
label = wrapper.ownerDocument.createElement("span");
label.dataset.cupLabel = "true";
wrapper.appendChild(label);
}
return label;
}
function setIfChanged(element, property, value) {
if (element.style[property] !== value) {
element.style[property] = value;
}
}
function enhanceProgressBar(progressBar) {
const planRow = findPlanRow(progressBar);
if (!planRow) {
return false;
}
const config = getLimitConfig(planRow.label);
const usedPercent = Number(progressBar.getAttribute("aria-valuenow"));
const remainingMinutes = parseResetMinutes(findResetText(planRow.label, planRow.row));
const expectedPercent = calculateExpectedPercent(config.windowMinutes, remainingMinutes);
const classification = classifyUsage(usedPercent, expectedPercent);
const color = CONFIG.colors[classification.tone] || CONFIG.colors.unknown;
const fill = getProgressFill(progressBar);
const marker = getOrCreateMarker(progressBar);
const label = getOrCreateLabel(progressBar);
const labelText = formatDeltaLabel(usedPercent, expectedPercent);
progressBar.dataset.cupProgress = "true";
progressBar.dataset.cupTone = classification.tone;
label.dataset.cupTone = classification.tone;
if (fill && color.fill) {
setIfChanged(fill, "backgroundColor", color.fill);
}
if (Number.isFinite(expectedPercent)) {
setIfChanged(marker, "display", "block");
setIfChanged(marker, "left", `calc(${expectedPercent}% - 1px)`);
setIfChanged(marker, "backgroundColor", color.marker);
} else {
setIfChanged(marker, "display", "none");
}
setIfChanged(label, "color", color.text);
if (label.textContent !== labelText) {
label.textContent = labelText;
}
return true;
}
function enhanceAll() {
ensureStyles(document);
const progressBars = Array.from(document.querySelectorAll('[role="progressbar"]'));
let enhancedCount = 0;
for (const progressBar of progressBars) {
if (enhanceProgressBar(progressBar)) {
enhancedCount += 1;
}
}
return enhancedCount;
}
function init() {
if (typeof document === "undefined") {
return;
}
let timer = null;
const schedule = () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
enhanceAll();
}, 150);
};
enhanceAll();
const observer = new MutationObserver(schedule);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
}
const api = {
CONFIG,
parseResetMinutes,
calculateExpectedPercent,
classifyUsage,
formatDeltaLabel,
getLimitConfig,
chooseResetText,
choosePlanLabel,
};
if (typeof module === "object" && module.exports) {
module.exports = {
ClaudeUsagePace: api,
};
} else {
init();
}
})();