Last active
February 9, 2026 10:25
-
-
Save TamasNo1/54929ad91e7e569947d5cc44da419fe2 to your computer and use it in GitHub Desktop.
Adds a simple linear weekly usage graph (100->0 over 7 days) + an X marker for current remaining usage.
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 ChatGPT Codex Usage: Weekly Linear Usage Graph | |
| // @namespace https://chatgpt.com/ | |
| // @version 0.0.3 | |
| // @description Adds a linear weekly usage graph (100->0 over 7 days) + an X marker for current remaining usage. | |
| // @match https://chatgpt.com/codex/settings/usage* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); | |
| function escapeXML(s) { | |
| return String(s) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function parseRemainingPercentFromCard(cardEl) { | |
| const txt = cardEl.textContent || ""; | |
| const m = txt.match(/(\d+)\s*%\s*remaining/i); | |
| if (!m) return null; | |
| return clamp(parseInt(m[1], 10), 0, 100); | |
| } | |
| function inferNextResetFromTimeOnly(timeStr) { | |
| // timeStr like "10:35 PM" | |
| const m = timeStr.match(/^\s*(\d{1,2}):(\d{2})\s*([AP]M)\s*$/i); | |
| if (!m) return null; | |
| let hh = parseInt(m[1], 10); | |
| const mm = parseInt(m[2], 10); | |
| const ap = m[3].toUpperCase(); | |
| // Convert to 24h | |
| if (ap === "AM") { | |
| if (hh === 12) hh = 0; | |
| } else { | |
| if (hh !== 12) hh += 12; | |
| } | |
| const now = new Date(); | |
| const d = new Date(now); | |
| d.setSeconds(0, 0); | |
| d.setHours(hh, mm, 0, 0); | |
| // If already passed today, next reset is tomorrow at that time | |
| if (d.getTime() <= now.getTime()) { | |
| d.setDate(d.getDate() + 1); | |
| } | |
| return d; | |
| } | |
| function parseResetDateFromCard(cardEl) { | |
| const txt = cardEl.textContent || ""; | |
| // Format A: "Resets Feb 9, 2026 10:35 PM" | |
| const full = txt.match( | |
| /Resets\s+([A-Za-z]{3,}\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}\s+[AP]M)/i | |
| ); | |
| if (full) { | |
| const d = new Date(full[1]); // local parse | |
| return isNaN(d.getTime()) ? null : d; | |
| } | |
| // Format B: "Resets 10:35 PM" | |
| const timeOnly = txt.match(/Resets\s+(\d{1,2}:\d{2}\s*[AP]M)\b/i); | |
| if (timeOnly) { | |
| const d = inferNextResetFromTimeOnly(timeOnly[1]); | |
| return d && !isNaN(d.getTime()) ? d : null; | |
| } | |
| return null; | |
| } | |
| function findWeeklyUsageCard() { | |
| const articles = Array.from(document.querySelectorAll("article")); | |
| return ( | |
| articles.find((a) => { | |
| const t = (a.textContent || "").toLowerCase(); | |
| return t.includes("weekly usage limit") && t.includes("resets"); | |
| }) || null | |
| ); | |
| } | |
| function formatTick(d) { | |
| const date = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); | |
| return { date, time }; | |
| } | |
| function createChartSVG({ remainingPct, resetDate, periodStart, now, width = 520, height = 200 }) { | |
| // Margins (left is larger so Y axis labels have room) | |
| const m = { l: 60, r: 24, t: 34, b: 52 }; | |
| const innerW = width - m.l - m.r; | |
| const innerH = height - m.t - m.b; | |
| const t0 = periodStart.getTime(); | |
| const t1 = resetDate.getTime(); | |
| const tn = now.getTime(); | |
| const xOfTime = (t) => m.l + clamp((t - t0) / (t1 - t0), 0, 1) * innerW; | |
| const yOfPct = (pct) => m.t + (1 - clamp(pct / 100, 0, 1)) * innerH; | |
| // Model line: 100 -> 0 across the period | |
| const x0 = xOfTime(t0); | |
| const x1 = xOfTime(t1); | |
| const y100 = yOfPct(100); | |
| const y0 = yOfPct(0); | |
| // Current marker: (now, remainingPct) | |
| const xNow = xOfTime(tn); | |
| const yNow = yOfPct(remainingPct); | |
| // X ticks every 24h from the start timestamp (includes time) | |
| const ticks = []; | |
| for (let i = 0; i <= 7; i++) { | |
| const ti = t0 + i * 24 * 60 * 60 * 1000; | |
| const di = new Date(ti); | |
| const { date, time } = formatTick(di); | |
| ticks.push({ x: xOfTime(ti), date, time }); | |
| } | |
| // Y ticks | |
| const yTicks = [0, 50, 100].map((v) => ({ v, y: yOfPct(v) })); | |
| // X marker drawing | |
| const xSize = 8; | |
| const xMark = ` | |
| <line x1="${xNow - xSize}" y1="${yNow - xSize}" x2="${xNow + xSize}" y2="${yNow + xSize}" stroke="currentColor" stroke-width="2" /> | |
| <line x1="${xNow - xSize}" y1="${yNow + xSize}" x2="${xNow + xSize}" y2="${yNow - xSize}" stroke="currentColor" stroke-width="2" /> | |
| `; | |
| const title = "Weekly usage (linear model)"; | |
| return ` | |
| <svg | |
| width="100%" | |
| viewBox="0 0 ${width} ${height}" | |
| preserveAspectRatio="xMidYMid meet" | |
| style="display:block; max-width:100%; width:100%; height:auto;" | |
| role="img" | |
| aria-label="${escapeXML(title)}" | |
| > | |
| <style> | |
| .bg { fill: white; } | |
| .frame { fill: none; stroke: rgba(15,23,42,0.18); stroke-width: 1; } | |
| .grid { stroke: rgba(15,23,42,0.08); stroke-width: 1; } | |
| .text { fill: rgba(15,23,42,0.75); font: 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; } | |
| .textMuted { fill: rgba(15,23,42,0.55); font: 11px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; } | |
| .lineModel { stroke: rgba(34,197,94,0.95); stroke-width: 2.5; fill: none; } | |
| .marker { color: rgba(239,68,68,0.95); } | |
| .dot { fill: rgba(239,68,68,0.95); } | |
| </style> | |
| <rect class="bg" x="0" y="0" width="${width}" height="${height}" rx="14" /> | |
| <text class="text" x="${m.l}" y="${m.t - 12}">${escapeXML(title)}</text> | |
| <rect class="frame" x="${m.l}" y="${m.t}" width="${innerW}" height="${innerH}" rx="10" /> | |
| ${yTicks | |
| .map( | |
| (t) => ` | |
| <line class="grid" x1="${m.l}" y1="${t.y}" x2="${m.l + innerW}" y2="${t.y}" /> | |
| <text class="textMuted" x="${m.l - 16}" y="${t.y + 4}" text-anchor="end">${t.v}%</text> | |
| ` | |
| ) | |
| .join("")} | |
| ${ticks | |
| .map( | |
| (t) => ` | |
| <line class="grid" x1="${t.x}" y1="${m.t}" x2="${t.x}" y2="${m.t + innerH}" /> | |
| <text class="textMuted" x="${t.x}" y="${m.t + innerH + 16}" text-anchor="middle"> | |
| <tspan x="${t.x}" dy="0">${escapeXML(t.date)}</tspan> | |
| <tspan x="${t.x}" dy="12">${escapeXML(t.time)}</tspan> | |
| </text> | |
| ` | |
| ) | |
| .join("")} | |
| <path class="lineModel" d="M ${x0} ${y100} L ${x1} ${y0}" /> | |
| <g class="marker"> | |
| ${xMark} | |
| <circle class="dot" cx="${xNow}" cy="${yNow}" r="2.5" /> | |
| </g> | |
| </svg> | |
| `; | |
| } | |
| function injectChart(cardEl, svgMarkup) { | |
| const existing = cardEl.querySelector("[data-tm-usage-chart]"); | |
| if (existing) existing.remove(); | |
| const wrap = document.createElement("div"); | |
| wrap.dataset.tmUsageChart = "1"; | |
| wrap.style.marginTop = "12px"; | |
| // Padding around the chart | |
| wrap.style.padding = "10px"; | |
| wrap.style.boxSizing = "border-box"; | |
| wrap.style.overflow = "visible"; | |
| const inner = document.createElement("div"); | |
| inner.style.width = "100%"; | |
| inner.style.maxWidth = "100%"; | |
| inner.innerHTML = svgMarkup; | |
| wrap.appendChild(inner); | |
| cardEl.appendChild(wrap); | |
| } | |
| function render() { | |
| const card = findWeeklyUsageCard(); | |
| if (!card) return; | |
| const remainingPct = parseRemainingPercentFromCard(card); | |
| const resetDate = parseResetDateFromCard(card); | |
| if (remainingPct == null || !resetDate) return; | |
| // Weekly period start is exactly 7 days before reset timestamp | |
| const periodStart = new Date(resetDate.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| const now = new Date(); | |
| const svg = createChartSVG({ remainingPct, resetDate, periodStart, now }); | |
| injectChart(card, svg); | |
| } | |
| render(); | |
| // SPA-safe rerender | |
| const mo = new MutationObserver(() => { | |
| if (render._raf) cancelAnimationFrame(render._raf); | |
| render._raf = requestAnimationFrame(render); | |
| }); | |
| mo.observe(document.documentElement, { childList: true, subtree: true }); | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is how it looks in your codex dashboard
