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; +}