diff --git a/content.js b/content.js
index 6e9fad1..2079bcd 100644
--- a/content.js
+++ b/content.js
@@ -644,93 +644,420 @@
return container;
}
- function injectTracker() {
- // Remove existing tracker if present
- const existing = document.getElementById('claude-usage-tracker');
- if (existing) {
- existing.remove();
+ // ===========================================================================
+ // Bootstrap nuevo (modal de Uso ahora es un
con hash
+ // #settings/usage sobre cualquier URL). Tres flujos:
+ // 1) Botón flotante (FAB) en todas las páginas de claude.ai.
+ // 2) Inyección del tracker dentro del modal real cuando el usuario lo abre.
+ // 3) Overlay propio abierto desde el FAB, con caché y auto-refresh.
+ // ===========================================================================
+
+ const CACHE_KEY = 'claude_usage_cache';
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 min
+ const FAB_ID = 'claude-usage-tracker-fab';
+ const OVERLAY_ID = 'claude-usage-tracker-overlay';
+ const TRACKER_ID = 'claude-usage-tracker';
+
+ let dialogObserver = null;
+ let refreshPromise = null;
+
+ // -------- Cache (chrome.storage.local) --------
+ function loadCache() {
+ return new Promise(resolve => {
+ try {
+ chrome.storage.local.get([CACHE_KEY], result => resolve(result[CACHE_KEY] || null));
+ } catch (e) {
+ resolve(null);
+ }
+ });
+ }
+
+ function saveCache(pageData, dailyData, source) {
+ try {
+ chrome.storage.local.set({
+ [CACHE_KEY]: {
+ pageData,
+ dailyData,
+ scrapedAt: new Date().toISOString(),
+ source: source || 'auto'
+ }
+ });
+ } catch (e) {
+ console.warn('[Claude Usage Tracker] No se pudo guardar caché:', e);
+ }
+ }
+
+ function cacheAgeMin(scrapedAt) {
+ if (!scrapedAt) return Infinity;
+ return (Date.now() - new Date(scrapedAt).getTime()) / 60000;
+ }
+
+ function formatAge(min) {
+ if (!isFinite(min)) return 'sin datos';
+ if (min < 1) return 'ahora';
+ if (min < 60) return `hace ${Math.floor(min)} min`;
+ const h = Math.floor(min / 60);
+ const m = Math.floor(min % 60);
+ return `hace ${h}h ${m}m`;
+ }
+
+ // -------- Detección del modal de Uso --------
+ function findUsageDialog() {
+ const dialogs = document.querySelectorAll('[role="dialog"]');
+ for (const d of dialogs) {
+ const txt = d.innerText || '';
+ if (txt.includes('Todos los modelos') || txt.includes('Sesión actual')) {
+ return d;
+ }
+ }
+ return null;
+ }
+
+ function isUsageDialogOpen() {
+ return !!findUsageDialog();
+ }
+
+ function dialogHasData(dialog) {
+ if (!dialog) return false;
+ return !!dialog.querySelector('[role="progressbar"][aria-valuenow]');
+ }
+
+ // -------- Inyección dentro del modal real --------
+ function findDialogInjectionAnchor(dialog) {
+ // Estructural, locale-proof: primer progressbar → subir al ancestro que
+ // también contiene la etiqueta "Todos los modelos" o "Sesión actual" — esa
+ // es la fila de la primera métrica, y prepend antes de ella.
+ const firstPb = dialog.querySelector('[role="progressbar"]');
+ if (!firstPb) return null;
+ let row = firstPb;
+ for (let i = 0; i < 12 && row; i++) {
+ const txt = row.innerText || '';
+ if (txt.includes('Todos los modelos') || txt.includes('Sesión actual')) {
+ return row;
+ }
+ row = row.parentElement;
+ }
+ return firstPb.parentElement;
+ }
+
+ function injectTrackerInDialog(dialog) {
+ if (!dialog) return false;
+ // Idempotencia: si ya hay un tracker DENTRO de este dialog, no duplicar.
+ if (dialog.querySelector('#' + TRACKER_ID)) return true;
+
+ const pageData = parsePageData();
+ if (pageData.weeklyUsage === 0 && pageData.resetDayIndex === -1) {
+ console.log('[Claude Usage Tracker] Modal abierto pero sin datos legibles aún');
+ return false;
}
initSession();
-
- // Parsear datos reales de la página
- const pageData = parsePageData();
-
- // Si no encontramos datos, no inyectar nada
- if (pageData.weeklyUsage === 0 && pageData.resetDayIndex === -1) {
- console.log('[Claude Usage Tracker] No se encontraron datos de uso en la página');
- return;
- }
-
const dailyData = calculateDailyUsage(pageData);
const tracker = createWeeklyTracker(pageData, dailyData);
-
- // Insertar justo DEBAJO del título principal h1
- const mainContent = document.querySelector('main');
- if (!mainContent) {
- console.log('[Claude Usage Tracker] No se encontró el main');
- return;
- }
-
- // Buscar el h1 del título principal "Ajustes" (visible en móvil)
- const mobileH1 = mainContent.querySelector('h1.font-heading');
-
- if (mobileH1) {
- // Insertar justo después del h1
- mobileH1.after(tracker);
+
+ const anchor = findDialogInjectionAnchor(dialog);
+ if (anchor && anchor.parentElement) {
+ anchor.parentElement.prepend(tracker);
} else {
- // Fallback: insertar al principio del main content
- const contentDiv = mainContent.querySelector('.pb-8') || mainContent.querySelector('div > div');
- if (contentDiv) {
- contentDiv.prepend(tracker);
- } else {
- mainContent.prepend(tracker);
- }
+ dialog.prepend(tracker);
}
- console.log('[Claude Usage Tracker] Inyectado en la parte superior', {
- pageData,
- dailyData
- });
+ saveCache(pageData, dailyData, 'manual');
+ updateFabState({ pageData, dailyData, scrapedAt: new Date().toISOString() });
+
+ console.log('[Claude Usage Tracker] Inyectado dentro del modal', { pageData });
+ return true;
}
- // Wait for page to fully load
- function waitForContent() {
- const observer = new MutationObserver((mutations, obs) => {
- const main = document.querySelector('main');
- const hasContent = main && main.textContent.length > 100;
-
- if (hasContent) {
+ // -------- Observers --------
+ function observeDialogForData(dialog, onReady) {
+ if (dialogHasData(dialog)) { onReady(); return; }
+ const obs = new MutationObserver(() => {
+ if (dialogHasData(dialog)) {
obs.disconnect();
- setTimeout(injectTracker, 500); // Small delay to ensure content is rendered
+ onReady();
}
});
-
- observer.observe(document.body, {
+ obs.observe(dialog, {
childList: true,
- subtree: true
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['aria-valuenow']
+ });
+ setTimeout(() => { obs.disconnect(); onReady(); }, 4000);
+ }
+
+ function startGlobalDialogWatcher() {
+ if (dialogObserver) return;
+ let lastDialog = null;
+ dialogObserver = new MutationObserver(() => {
+ const dialog = findUsageDialog();
+ if (dialog && dialog !== lastDialog) {
+ lastDialog = dialog;
+ observeDialogForData(dialog, () => injectTrackerInDialog(dialog));
+ } else if (!dialog) {
+ lastDialog = null;
+ }
+ updateFabVisibility();
+ });
+ dialogObserver.observe(document.body, { childList: true, subtree: true });
+ }
+
+ // -------- FAB --------
+ function injectFloatingButton() {
+ if (document.getElementById(FAB_ID)) return;
+ const fab = document.createElement('button');
+ fab.id = FAB_ID;
+ fab.className = 'claude-usage-tracker-fab';
+ fab.title = 'Claude Usage Tracker';
+ fab.innerHTML = `
+
📊
+
…
+ `;
+ fab.addEventListener('click', onFabClick);
+ document.body.appendChild(fab);
+ loadCache().then(updateFabState);
+ }
+
+ function updateFabState(cache) {
+ const fab = document.getElementById(FAB_ID);
+ if (!fab) return;
+ const textEl = fab.querySelector('.claude-usage-tracker-fab-text');
+ if (!textEl) return;
+ if (!cache || !cache.pageData) { textEl.textContent = '—'; return; }
+ const s = Math.round(cache.pageData.sessionUsage || 0);
+ const w = Math.round(cache.pageData.weeklyUsage || 0);
+ textEl.textContent = `${s}% / ${w}%`;
+ }
+
+ function updateFabVisibility() {
+ const fab = document.getElementById(FAB_ID);
+ if (!fab) return;
+ if (isUsageDialogOpen()) fab.classList.add('claude-usage-tracker-fab--hidden');
+ else fab.classList.remove('claude-usage-tracker-fab--hidden');
+ }
+
+ async function onFabClick() {
+ const cache = await loadCache();
+ openOverlayPanel(cache);
+ // Auto-refresh si la caché está rancia
+ const ageMin = cache ? cacheAgeMin(cache.scrapedAt) : Infinity;
+ if (ageMin > CACHE_TTL_MS / 60000) {
+ refreshInBackground('overlay-stale');
+ }
+ }
+
+ // -------- Overlay propio --------
+ function openOverlayPanel(cache) {
+ closeOverlayPanel();
+ const overlay = document.createElement('div');
+ overlay.id = OVERLAY_ID;
+ overlay.className = 'claude-usage-tracker-overlay';
+ overlay.addEventListener('click', e => {
+ if (e.target === overlay) closeOverlayPanel();
});
- // Timeout fallback
- setTimeout(() => {
- observer.disconnect();
- injectTracker();
- }, 3000);
- }
+ const panel = document.createElement('div');
+ panel.className = 'claude-usage-tracker-overlay-panel';
- // Initialize
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', waitForContent);
- } else {
- waitForContent();
- }
+ const header = document.createElement('div');
+ header.className = 'claude-usage-tracker-overlay-header';
+ const ageMin = cache ? cacheAgeMin(cache.scrapedAt) : Infinity;
+ header.innerHTML = `
+
${formatAge(ageMin)}
+
+
+
+
+ `;
+ header.querySelector('.claude-usage-tracker-overlay-close').addEventListener('click', closeOverlayPanel);
+ header.querySelector('.claude-usage-tracker-overlay-refresh').addEventListener('click', () => refreshInBackground('overlay-manual'));
- // Update session time periodically
- setInterval(() => {
- const sessionValue = document.querySelector('.session-info-value');
- if (sessionValue) {
- injectTracker();
+ const body = document.createElement('div');
+ body.className = 'claude-usage-tracker-overlay-body';
+
+ if (cache && cache.pageData) {
+ body.appendChild(createWeeklyTracker(cache.pageData, cache.dailyData));
+ } else {
+ body.innerHTML = '
Sin datos aún. Refrescando…
';
+ setTimeout(() => refreshInBackground('overlay-empty'), 0);
}
- }, 60000); // Update every minute
+
+ panel.appendChild(header);
+ panel.appendChild(body);
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+
+ document.addEventListener('keydown', overlayEscapeHandler);
+ }
+
+ function overlayEscapeHandler(e) {
+ if (e.key === 'Escape') closeOverlayPanel();
+ }
+
+ function closeOverlayPanel() {
+ const ov = document.getElementById(OVERLAY_ID);
+ if (ov) ov.remove();
+ document.removeEventListener('keydown', overlayEscapeHandler);
+ }
+
+ function setOverlaySpinner(active) {
+ const ov = document.getElementById(OVERLAY_ID);
+ if (!ov) return;
+ let spinner = ov.querySelector('.claude-usage-tracker-overlay-spinner');
+ if (active && !spinner) {
+ spinner = document.createElement('span');
+ spinner.className = 'claude-usage-tracker-overlay-spinner';
+ spinner.textContent = '⟳ actualizando…';
+ const header = ov.querySelector('.claude-usage-tracker-overlay-header');
+ if (header) header.appendChild(spinner);
+ } else if (!active && spinner) {
+ spinner.remove();
+ }
+ }
+
+ function renderOverlayWithCache(cache) {
+ const ov = document.getElementById(OVERLAY_ID);
+ if (!ov) return;
+ const body = ov.querySelector('.claude-usage-tracker-overlay-body');
+ const age = ov.querySelector('.claude-usage-tracker-overlay-age');
+ if (body) {
+ body.innerHTML = '';
+ if (cache && cache.pageData) {
+ body.appendChild(createWeeklyTracker(cache.pageData, cache.dailyData));
+ } else {
+ body.innerHTML = '
No he podido cargar los datos. Abre Uso manualmente.
';
+ }
+ }
+ if (age && cache) age.textContent = formatAge(cacheAgeMin(cache.scrapedAt));
+ }
+
+ function showOverlayError(msg) {
+ const ov = document.getElementById(OVERLAY_ID);
+ if (!ov) return;
+ const body = ov.querySelector('.claude-usage-tracker-overlay-body');
+ if (body) body.innerHTML = `
${msg}
`;
+ }
+
+ // -------- Refresh: abrir modal en background, scrapear, cerrar --------
+ function waitForDialogWithData(timeoutMs) {
+ return new Promise(resolve => {
+ const t0 = Date.now();
+ const check = () => {
+ const d = findUsageDialog();
+ if (d && dialogHasData(d)) return resolve(d);
+ if (Date.now() - t0 > timeoutMs) return resolve(null);
+ setTimeout(check, 80);
+ };
+ check();
+ });
+ }
+
+ async function tryOpenViaPushState() {
+ history.pushState(null, '', '#settings/usage');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ return waitForDialogWithData(700);
+ }
+
+ async function tryOpenViaHash() {
+ location.hash = '#settings/usage';
+ return waitForDialogWithData(700);
+ }
+
+ async function tryOpenViaClick() {
+ const candidates = [...document.querySelectorAll('a, button')]
+ .filter(el => (el.innerText || '').trim() === 'Uso');
+ if (candidates.length === 0) return null;
+ candidates[0].click();
+ return waitForDialogWithData(1500);
+ }
+
+ function closeUsageModal() {
+ const dialog = findUsageDialog();
+ if (!dialog) return;
+ const closeBtn = [...dialog.querySelectorAll('button')]
+ .find(b => /close|cerrar/i.test(b.getAttribute('aria-label') || ''));
+ if (closeBtn) { closeBtn.click(); return; }
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ }
+
+ async function refreshInBackground(reason) {
+ if (refreshPromise) return refreshPromise;
+ refreshPromise = (async () => {
+ console.log('[Claude Usage Tracker] refresh:', reason);
+ setOverlaySpinner(true);
+
+ const previousHash = location.hash;
+ const wasOpen = isUsageDialogOpen();
+
+ let dialog = wasOpen ? findUsageDialog() : null;
+ if (!dialog) dialog = await tryOpenViaPushState();
+ if (!dialog) dialog = await tryOpenViaHash();
+ if (!dialog) dialog = await tryOpenViaClick();
+
+ if (!dialog) {
+ console.warn('[Claude Usage Tracker] no he podido abrir el modal automáticamente');
+ setOverlaySpinner(false);
+ showOverlayError('No he podido refrescar automáticamente — abre Uso manualmente y vuelve.');
+ refreshPromise = null;
+ return;
+ }
+
+ const pageData = parsePageData();
+ let cache = null;
+ if (pageData.weeklyUsage > 0 || pageData.resetDayIndex >= 0) {
+ initSession();
+ const dailyData = calculateDailyUsage(pageData);
+ saveCache(pageData, dailyData, reason === 'overlay-manual' ? 'manual' : 'auto');
+ cache = { pageData, dailyData, scrapedAt: new Date().toISOString() };
+ updateFabState(cache);
+ }
+
+ // Cerrar modal solo si nosotros lo abrimos
+ if (!wasOpen) {
+ closeUsageModal();
+ if (location.hash !== previousHash) {
+ history.replaceState(null, '', location.pathname + location.search + previousHash);
+ }
+ }
+
+ setOverlaySpinner(false);
+ renderOverlayWithCache(cache);
+ refreshPromise = null;
+ })();
+ return refreshPromise;
+ }
+
+ // -------- Bootstrap --------
+ function bootstrap() {
+ injectFloatingButton();
+ startGlobalDialogWatcher();
+
+ window.addEventListener('hashchange', () => {
+ updateFabVisibility();
+ const dialog = findUsageDialog();
+ if (dialog) observeDialogForData(dialog, () => injectTrackerInDialog(dialog));
+ });
+ window.addEventListener('popstate', updateFabVisibility);
+
+ // Sanity check: re-inyectar FAB si React lo eliminó
+ setInterval(() => {
+ injectFloatingButton();
+ updateFabVisibility();
+ }, 2000);
+
+ // Si el modal ya estaba abierto al cargar
+ setTimeout(() => {
+ const dialog = findUsageDialog();
+ if (dialog) observeDialogForData(dialog, () => injectTrackerInDialog(dialog));
+ updateFabVisibility();
+ }, 500);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', bootstrap);
+ } else {
+ bootstrap();
+ }
})();
diff --git a/manifest.json b/manifest.json
index 6194795..df98b45 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,7 +6,7 @@
"permissions": ["storage"],
"content_scripts": [
{
- "matches": ["https://claude.ai/settings/usage*"],
+ "matches": ["https://claude.ai/*"],
"js": ["content.js"],
"css": ["styles.css"],
"run_at": "document_idle"
diff --git a/styles.css b/styles.css
index 8cecf7c..47b1753 100644
--- a/styles.css
+++ b/styles.css
@@ -524,3 +524,166 @@
color: var(--text-300, #a3a3a3);
font-size: 12px;
}
+
+/* ===========================================================================
+ Floating button (FAB) — visible en todas las páginas de claude.ai
+ =========================================================================== */
+.claude-usage-tracker-fab {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 999998;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: rgba(20, 20, 22, 0.92);
+ color: #f5f5f5;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 999px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ transition: transform 0.15s ease, box-shadow 0.2s ease, opacity 0.2s ease;
+}
+
+.claude-usage-tracker-fab:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
+ border-color: rgba(255, 255, 255, 0.22);
+}
+
+.claude-usage-tracker-fab:active {
+ transform: translateY(0);
+}
+
+.claude-usage-tracker-fab--hidden {
+ display: none !important;
+}
+
+.claude-usage-tracker-fab-icon {
+ font-size: 16px;
+ line-height: 1;
+}
+
+.claude-usage-tracker-fab-text {
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.01em;
+}
+
+/* ===========================================================================
+ Overlay propio — abierto al click del FAB
+ =========================================================================== */
+.claude-usage-tracker-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 999999;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 60px 16px 40px;
+ overflow-y: auto;
+ animation: claude-tracker-fade-in 0.15s ease;
+}
+
+@keyframes claude-tracker-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.claude-usage-tracker-overlay-panel {
+ width: 100%;
+ max-width: 720px;
+ background: #1a1a1d;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 14px;
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
+ padding: 16px 20px 20px;
+ color: #f5f5f5;
+ font-family: inherit;
+}
+
+.claude-usage-tracker-overlay-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ flex-wrap: wrap;
+}
+
+.claude-usage-tracker-overlay-age {
+ font-size: 12px;
+ color: var(--text-400, #a3a3a3);
+ font-style: italic;
+}
+
+.claude-usage-tracker-overlay-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+ align-items: center;
+}
+
+.claude-usage-tracker-overlay-refresh,
+.claude-usage-tracker-overlay-close {
+ background: rgba(255, 255, 255, 0.06);
+ color: #f5f5f5;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.claude-usage-tracker-overlay-refresh:hover,
+.claude-usage-tracker-overlay-close:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.22);
+}
+
+.claude-usage-tracker-overlay-close {
+ padding: 6px 10px;
+}
+
+.claude-usage-tracker-overlay-spinner {
+ font-size: 12px;
+ color: #06b6d4;
+ font-style: italic;
+ margin-left: 8px;
+ animation: claude-tracker-spin-pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes claude-tracker-spin-pulse {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+}
+
+.claude-usage-tracker-overlay-body {
+ min-height: 80px;
+}
+
+.claude-usage-tracker-overlay-empty {
+ padding: 40px 16px;
+ text-align: center;
+ color: var(--text-300, #a3a3a3);
+ font-size: 14px;
+}
+
+/* Cuando el tracker se inyecta dentro de nuestro overlay, ya está dentro de un
+ contenedor con padding/borde; quitamos su margen superior para que pegue. */
+.claude-usage-tracker-overlay-body .claude-usage-tracker-container {
+ margin-top: 0;
+}