Claude Usage HUD

Floating widget showing claude.ai message quota usage (5h & 7d).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Claude Usage HUD
// @namespace    foxbinner
// @version      2.0.0
// @description  Floating widget showing claude.ai message quota usage (5h & 7d).
// @match        https://claude.ai/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  if (document.getElementById('cuw')) return;

  const POLL_INTERVAL_MS = 60_000;
  const R    = 20;
  const CIRC = 2 * Math.PI * R;
  const ARC  = CIRC * 0.75;

  function arcOffset(pct) {
    const filled = ARC * (pct / 100);
    return `${filled} ${CIRC - filled}`;
  }

  function getColor(pct) {
    if (pct < 50) return '#7dbd8e';
    if (pct < 75) return '#d4a847';
    if (pct < 90) return '#e8825a';
    return '#d95f5f';
  }

  function dotClass(pct) {
    if (pct < 50) return 'ok';
    if (pct < 75) return 'warn';
    if (pct < 90) return 'hot';
    return 'crit';
  }

  function formatReset(iso) {
    if (!iso) return '';
    const ms = new Date(iso) - Date.now();
    if (ms <= 0) return 'Resetting…';
    const h = Math.floor(ms / 3_600_000);
    const m = Math.floor((ms % 3_600_000) / 60_000);
    if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h left`;
    if (h > 0)   return `${h}h ${m}m left`;
    return `${m}m left`;
  }

  const style = document.createElement('style');
  style.textContent = `
    #cuw {
      position: fixed;
      bottom: 16px;
      right: 16px;
      z-index: 999999;
      width: 180px;
      border-radius: 14px;
      background: #1e1d1a;
      border: 1px solid rgba(255,255,255,0.13);
      box-shadow:
        0 2px 8px rgba(0,0,0,0.3),
        0 8px 32px rgba(0,0,0,0.35),
        inset 0 1px 0 rgba(255,255,255,0.08);
      font-family: ui-sans-serif, system-ui, sans-serif;
      overflow: hidden;
      user-select: none;
      cursor: default;
    }
    #cuw-topline {
      height: 1.5px;
      background: linear-gradient(90deg,
        transparent 0%,
        rgba(232,130,90,0.5) 40%,
        rgba(212,168,71,0.4) 60%,
        transparent 100%
      );
    }
    #cuw-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 7px 11px 4px;
    }
    #cuw-title {
      font-size: 9.5px;
      font-weight: 600;
      letter-spacing: 0.8px;
      text-transform: uppercase;
      color: rgba(255,255,255,0.55);
    }
    #cuw-dot {
      width: 5px;
      height: 5px;
      border-radius: 50%;
      transition: background 0.4s, box-shadow 0.4s;
    }
    #cuw-dot.ok   { background: #7dbd8e; box-shadow: 0 0 5px #7dbd8e55; }
    #cuw-dot.warn { background: #d4a847; box-shadow: 0 0 5px #d4a84766; }
    #cuw-dot.hot  { background: #e8825a; box-shadow: 0 0 5px #e8825a66; }
    #cuw-dot.crit { background: #d95f5f; box-shadow: 0 0 5px #d95f5f88; }
    #cuw-divider {
      height: 1px;
      background: rgba(255,255,255,0.08);
      margin: 0 10px;
    }
    #cuw-gauges {
      display: flex;
      justify-content: space-around;
      align-items: center;
      padding: 8px 8px 10px;
      gap: 6px;
    }
    .cuw-gauge {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 5px;
      flex: 1;
    }
    .cuw-arc-wrap {
      position: relative;
      width: 56px;
      height: 56px;
    }
    .cuw-arc-wrap svg {
      width: 100%;
      height: 100%;
      transform: rotate(135deg);
    }
    .cuw-ring-track {
      fill: none;
      stroke: rgba(255,255,255,0.12);
      stroke-width: 3.5;
      stroke-linecap: round;
      stroke-dasharray: 94.25 31.41;
    }
    .cuw-ring-fill {
      fill: none;
      stroke-width: 3.5;
      stroke-linecap: round;
      transition: stroke-dasharray 0.8s cubic-bezier(.4,0,.2,1), stroke 0.4s;
    }
    .cuw-arc-center {
      position: absolute;
      inset: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      transform: translateY(3px);
    }
    .cuw-arc-pct {
      font-size: 13px;
      font-weight: 700;
      color: rgba(255,255,255,0.88);
      font-variant-numeric: tabular-nums;
      line-height: 1;
      transition: color 0.4s;
    }
    .cuw-arc-label {
      font-size: 7.5px;
      font-weight: 600;
      letter-spacing: 0.5px;
      text-transform: uppercase;
      color: rgba(255,255,255,0.5);
      margin-top: 2px;
    }
    .cuw-reset {
      font-size: 8.5px;
      color: rgba(255,255,255,0.45);
      text-align: center;
      font-variant-numeric: tabular-nums;
      line-height: 1.3;
      min-height: 11px;
    }
    .cuw-vdiv {
      width: 1px;
      height: 48px;
      background: rgba(255,255,255,0.1);
      flex-shrink: 0;
    }
  `;
  document.head.appendChild(style);

  function makeArcSVG(id) {
    return `<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
      <circle class="cuw-ring-track" cx="22" cy="22" r="${R}"
        stroke-dasharray="${ARC} ${CIRC - ARC}" />
      <circle class="cuw-ring-fill" id="${id}" cx="22" cy="22" r="${R}" />
    </svg>`;
  }

  function makeGauge(key, ringId) {
    return `<div class="cuw-gauge">
      <div class="cuw-arc-wrap">
        ${makeArcSVG(ringId)}
        <div class="cuw-arc-center">
          <span class="cuw-arc-pct" id="${ringId}-pct">—</span>
          <span class="cuw-arc-label">${key}</span>
        </div>
      </div>
      <div class="cuw-reset" id="${ringId}-reset"></div>
    </div>`;
  }

  const widget = document.createElement('div');
  widget.id = 'cuw';
  widget.innerHTML = `
    <div id="cuw-topline"></div>
    <div id="cuw-header">
      <span id="cuw-title">Usage</span>
      <div id="cuw-dot" class="ok"></div>
    </div>
    <div id="cuw-divider"></div>
    <div id="cuw-gauges">
      ${makeGauge('5h', 'cuw-5h')}
      <div class="cuw-vdiv"></div>
      ${makeGauge('7d', 'cuw-7d')}
    </div>
  `;
  document.body.appendChild(widget);

  const dot = widget.querySelector('#cuw-dot');

  const gauges = {
    '5h': {
      ring:  widget.querySelector('#cuw-5h'),
      pct:   widget.querySelector('#cuw-5h-pct'),
      reset: widget.querySelector('#cuw-5h-reset'),
    },
    '7d': {
      ring:  widget.querySelector('#cuw-7d'),
      pct:   widget.querySelector('#cuw-7d-pct'),
      reset: widget.querySelector('#cuw-7d-reset'),
    },
  };

  widget.querySelectorAll('.cuw-ring-fill').forEach(r => {
    r.style.strokeDasharray = `0 ${CIRC}`;
  });

  function updateGauge(key, utilization, resetsAt) {
    const pct   = Math.min(Math.max(Math.round(utilization), 0), 100);
    const color = getColor(pct);
    const { ring, pct: pctEl, reset } = gauges[key];
    ring.style.stroke          = color;
    ring.style.strokeDasharray = arcOffset(pct);
    pctEl.textContent          = pct + '%';
    pctEl.style.color          = color;
    reset.textContent          = formatReset(resetsAt);
    return pct;
  }

  async function fetchUsage() {
    try {
      const orgsRes = await fetch('/api/organizations', {
        credentials: 'include',
        headers: { Accept: 'application/json' },
      });
      if (!orgsRes.ok) return;

      const orgs = await orgsRes.json();
      if (!orgs?.length) return;

      const usageRes = await fetch(`/api/organizations/${orgs[0].uuid}/usage`, {
        credentials: 'include',
        headers: { Accept: 'application/json' },
      });
      if (!usageRes.ok) return;

      const { five_hour, seven_day } = await usageRes.json();
      const pcts = [
        five_hour && updateGauge('5h', five_hour.utilization, five_hour.resets_at),
        seven_day && updateGauge('7d', seven_day.utilization, seven_day.resets_at),
      ].filter(Boolean);

      dot.className = dotClass(Math.max(...pcts, 0));
    } catch (err) {
      console.warn('[Claude Usage HUD]', err.message);
    }
  }

  fetchUsage();
  setInterval(fetchUsage, POLL_INTERVAL_MS);
})();