Claude Usage Pace

Show whether Claude plan usage is ahead of or behind a linear limit pace.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
  }
})();