// Claude Usage Tracker - Content Script (function() { 'use strict'; // Días en español - índice 0 = Domingo const DAYS_ES = ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb']; const DAYS_ES_DISPLAY = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; const DAYS_FULL_ES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; // Boost 2× promotion config (March 2026) // Peak hours: 8AM - 2PM ET (Eastern Time, UTC-4 in March) // Outside peak = 2× boost const BOOST_END_DATE = new Date('2026-03-28T06:59:00Z'); // March 27, 11:59 PM PT = March 28, 6:59 AM UTC const PEAK_START_UTC = 12; // 8AM ET = 12:00 UTC const PEAK_END_UTC = 18; // 2PM ET = 18:00 UTC // Session tracking let sessionStartTime = null; const SESSION_KEY = 'claude_usage_tracker_session'; function initSession() { const stored = localStorage.getItem(SESSION_KEY); if (stored) { const data = JSON.parse(stored); const storedDate = new Date(data.startTime); const now = new Date(); if (storedDate.toDateString() === now.toDateString() && (now - storedDate) < 4 * 60 * 60 * 1000) { sessionStartTime = new Date(data.startTime); } else { sessionStartTime = new Date(); saveSession(); } } else { sessionStartTime = new Date(); saveSession(); } } function saveSession() { localStorage.setItem(SESSION_KEY, JSON.stringify({ startTime: sessionStartTime.toISOString() })); } function formatTime(date) { return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); } /** * Calcula el estado del boost 2× * Retorna { isActive, isBoostPeriod, nextChangeIn, nextChangeTime, promotionEnded } */ function getBoostStatus() { const now = new Date(); // Verificar si la promoción ha terminado if (now >= BOOST_END_DATE) { return { isActive: false, isBoostPeriod: false, promotionEnded: true }; } const utcHour = now.getUTCHours(); const utcMinutes = now.getUTCMinutes(); // Peak hours: 12:00 - 18:00 UTC (8AM - 2PM ET) const isPeakHour = utcHour >= PEAK_START_UTC && utcHour < PEAK_END_UTC; const isBoostPeriod = !isPeakHour; // Boost es cuando NO es hora pico // Calcular tiempo hasta el próximo cambio let nextChangeHour; if (isPeakHour) { // Estamos en pico, próximo cambio es cuando termine (18:00 UTC) nextChangeHour = PEAK_END_UTC; } else { // Estamos en boost, próximo cambio es cuando empiece el pico if (utcHour < PEAK_START_UTC) { nextChangeHour = PEAK_START_UTC; } else { // Después de las 18:00 UTC, el próximo pico es mañana a las 12:00 UTC nextChangeHour = PEAK_START_UTC + 24; } } const minutesUntilChange = (nextChangeHour * 60) - (utcHour * 60 + utcMinutes); const hoursUntilChange = Math.floor(minutesUntilChange / 60); const minsUntilChange = minutesUntilChange % 60; // Calcular hora local del próximo cambio const nextChangeTime = new Date(now); nextChangeTime.setUTCHours(nextChangeHour % 24, 0, 0, 0); if (nextChangeHour >= 24) { nextChangeTime.setDate(nextChangeTime.getDate() + 1); } return { isActive: true, isBoostPeriod, nextChangeIn: `${hoursUntilChange}h ${minsUntilChange}m`, nextChangeTime: formatTime(nextChangeTime), promotionEnded: false }; } function formatDuration(ms) { const hours = Math.floor(ms / (1000 * 60 * 60)); const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) { return `${hours}h ${minutes}m`; } return `${minutes}m`; } const SESSION_DURATION_HOURS = 5; // Las sesiones de Claude duran 5 horas /** * Parsea el tiempo restante desde texto como "Se restablece en 3 h 44 min" * Retorna el tiempo restante en minutos */ function parseTimeRemaining(text) { const match = text.match(/(\d+)\s*h\s*(\d+)\s*min/i); if (match) { return parseInt(match[1]) * 60 + parseInt(match[2]); } // Solo minutos: "Se restablece en 44 min" const minMatch = text.match(/(\d+)\s*min/i); if (minMatch) { return parseInt(minMatch[1]); } // Solo horas: "Se restablece en 3 h" const hourMatch = text.match(/(\d+)\s*h/i); if (hourMatch) { return parseInt(hourMatch[1]) * 60; } return 0; } /** * Encuentra el elemento "hoja" cuyo texto coincide exactamente con el texto dado. * Se ancla en el TEXTO, no en clases CSS, para ser robusto ante los cambios * de design tokens de Claude (p.ej. text-text-100 desapareció en mayo 2026). */ function findLabelElement(text) { let best = null; const candidates = document.querySelectorAll('p, span, div, h2, h3, h4'); for (const el of candidates) { if (el.textContent.trim() === text) { // Preferir el match más interno (menor longitud de texto = más cercano a la hoja) if (!best || el.textContent.length <= best.textContent.length) { best = el; } } } return best; } /** * Dada la etiqueta de una métrica (p.ej. "Todos los modelos" o "Sesión actual"), * localiza su fila subiendo desde la etiqueta hasta el primer ancestro que * contiene un [role="progressbar"], y devuelve { usage, resetText }. * No depende de nombres de clase CSS de Claude. */ function getMetricByLabel(labelText) { const labelEl = findLabelElement(labelText); if (!labelEl) return null; let container = labelEl; for (let i = 0; i < 10 && container; i++) { if (container.querySelector('[role="progressbar"]')) break; container = container.parentElement; } const progressBar = container && container.querySelector('[role="progressbar"]'); if (!progressBar) return null; const usage = parseFloat(progressBar.getAttribute('aria-valuenow')) || 0; const containerText = container.innerText || container.textContent || ''; const resetMatch = containerText.match(/Se restablece[^\n]*/); const resetText = resetMatch ? resetMatch[0].trim() : ''; return { usage, resetText }; } /** * Parsea la información de la página real de Claude * Busca los progressbar con aria-valuenow y el texto "Se restablece..." */ function parsePageData() { const data = { sessionUsage: 0, sessionResetText: '', sessionTimeRemainingMin: 0, sessionStartTime: null, sessionEndTime: null, weeklyUsage: 0, weeklyResetText: '', resetDayIndex: -1, resetHour: '', daysUntilReset: 0, daysPassed: 0 }; // Sesión actual const session = getMetricByLabel('Sesión actual'); if (session) { data.sessionUsage = session.usage; data.sessionResetText = session.resetText; // Calcular tiempo restante y horas de inicio/fin data.sessionTimeRemainingMin = parseTimeRemaining(session.resetText); if (data.sessionTimeRemainingMin > 0) { const now = new Date(); const sessionDurationMin = SESSION_DURATION_HOURS * 60; const elapsedMin = sessionDurationMin - data.sessionTimeRemainingMin; // Hora de inicio = ahora - tiempo transcurrido data.sessionStartTime = new Date(now.getTime() - elapsedMin * 60 * 1000); // Hora de fin = ahora + tiempo restante data.sessionEndTime = new Date(now.getTime() + data.sessionTimeRemainingMin * 60 * 1000); } } else { console.log('[Claude Usage Tracker] No se encontró la métrica "Sesión actual"'); } // Todos los modelos (uso semanal) const weekly = getMetricByLabel('Todos los modelos'); if (weekly) { data.weeklyUsage = weekly.usage; data.weeklyResetText = weekly.resetText; // Parsear "Se restablece vie, 4:59" para obtener día y hora const resetMatch = weekly.resetText.match(/Se restablece\s+([a-záéíóúñü]+),?\s*([\d:]+)/i); if (resetMatch) { const dayAbbr = resetMatch[1].toLowerCase(); data.resetHour = resetMatch[2]; // Encontrar el índice del día (comparar primeras 3 letras sin acentos) const normalize = s => s.normalize('NFD').replace(/[̀-ͯ]/g, ''); const dayAbbrNorm = normalize(dayAbbr); data.resetDayIndex = DAYS_ES.findIndex(d => { const dNorm = normalize(d); return dNorm === dayAbbrNorm || dNorm.startsWith(dayAbbrNorm.substring(0, 3)) || dayAbbrNorm.startsWith(dNorm.substring(0, 3)); }); console.log('[Claude Usage Tracker] Reset parsed:', dayAbbr, '->', data.resetDayIndex, 'hour:', data.resetHour); } else { console.log('[Claude Usage Tracker] Reset text no match:', weekly.resetText); } } else { console.log('[Claude Usage Tracker] No se encontró la métrica "Todos los modelos"'); } // Calcular días hasta el reinicio y días pasados if (data.resetDayIndex >= 0) { const now = new Date(); const todayIndex = now.getDay(); // 0 = Domingo, 1 = Lunes, etc. // Calcular días hasta el reinicio let daysUntil = data.resetDayIndex - todayIndex; if (daysUntil <= 0) { daysUntil += 7; } // Si es el mismo día pero ya pasó la hora, es la próxima semana if (daysUntil === 7 || (daysUntil === 0 && data.resetHour)) { const [resetH, resetM] = data.resetHour.split(':').map(Number); if (now.getHours() > resetH || (now.getHours() === resetH && now.getMinutes() >= resetM)) { daysUntil = 7; } } data.daysUntilReset = daysUntil; data.daysPassed = 7 - daysUntil; } return data; } /** * Calcula el desglose diario basado en los datos reales * Usa HORAS transcurridas para mayor precisión * Divide el día de reinicio en dos partes: inicio (después del reinicio) y fin (antes del reinicio) */ function calculateDailyUsage(pageData) { const { weeklyUsage, resetDayIndex, resetHour } = pageData; const now = new Date(); const todayIndex = now.getDay(); const currentHour = now.getHours() + now.getMinutes() / 60; // Calcular el momento exacto del último reinicio const [resetH, resetM] = (resetHour || '5:00').split(':').map(Number); const resetHourDecimal = resetH + (resetM || 0) / 60; // Encontrar la fecha del último reinicio (usar effectiveResetDayIndex más abajo) const effectiveResetIdx = resetDayIndex >= 0 ? resetDayIndex : 5; let lastResetDate = new Date(now); let daysSinceReset = todayIndex - effectiveResetIdx; if (daysSinceReset < 0) daysSinceReset += 7; if (daysSinceReset === 0) { // Es el día de reinicio, ¿ya pasó la hora? if (currentHour < resetHourDecimal) { daysSinceReset = 7; // Aún no ha reiniciado, usar el de la semana pasada } } lastResetDate.setDate(now.getDate() - daysSinceReset); lastResetDate.setHours(resetH, resetM || 0, 0, 0); // Horas totales transcurridas desde el reinicio const msElapsed = now - lastResetDate; const hoursElapsed = msElapsed / (1000 * 60 * 60); const totalWeekHours = 7 * 24; // 168 horas // Porcentaje de la semana transcurrido const weekProgressPercent = (hoursElapsed / totalWeekHours) * 100; // Uso ideal hasta ahora (proporcional a las horas transcurridas) const idealUsedByNow = weekProgressPercent; // Uso ideal por día (para referencia visual) const idealDailyPercent = 100 / 7; // Promedio real de uso por hora const avgUsagePerHour = hoursElapsed > 0 ? weeklyUsage / hoursElapsed : 0; const idealUsagePerHour = 100 / totalWeekHours; // Nombre del día de reinicio (con fallback, reutilizar effectiveResetIdx) const effectiveResetDayIndex = effectiveResetIdx; const resetDayName = DAYS_ES_DISPLAY[effectiveResetDayIndex] || 'Vie'; const resetDayFullName = DAYS_FULL_ES[effectiveResetDayIndex] || 'Viernes'; const dailyBreakdown = []; // Estructura: 8 segmentos // 0: Día reinicio (inicio) - desde resetHour hasta 24:00 // 1-6: Días completos (sáb, dom, lun, mar, mié, jue si reinicio es viernes) // 7: Día reinicio (fin) - desde 00:00 hasta resetHour for (let i = 0; i < 8; i++) { let dayIndex, dayName, dayFullName, dayTotalHours, isResetDayStart, isResetDayEnd; if (i === 0) { // Primer segmento: día de reinicio (parte después del reinicio) dayIndex = effectiveResetDayIndex; dayName = resetDayName; dayFullName = resetDayFullName + ' (inicio)'; dayTotalHours = 24 - resetHourDecimal; isResetDayStart = true; isResetDayEnd = false; } else if (i === 7) { // Último segmento: día de reinicio (parte antes del reinicio) dayIndex = effectiveResetDayIndex; dayName = resetDayName; dayFullName = resetDayFullName + ' (fin)'; dayTotalHours = resetHourDecimal; isResetDayStart = false; isResetDayEnd = true; } else { // Días intermedios (completos) dayIndex = (effectiveResetDayIndex + i) % 7; dayName = DAYS_ES_DISPLAY[dayIndex]; dayFullName = DAYS_FULL_ES[dayIndex]; dayTotalHours = 24; isResetDayStart = false; isResetDayEnd = false; } // Calcular posición del día actual let segmentPosition; if (todayIndex === effectiveResetDayIndex) { // Hoy es el día de reinicio if (currentHour >= resetHourDecimal) { segmentPosition = 0; // Estamos en la parte de inicio } else { segmentPosition = 7; // Estamos en la parte de fin (antes del reinicio) } } else { // Día normal let pos = todayIndex - effectiveResetDayIndex; if (pos <= 0) pos += 7; segmentPosition = pos; } const isToday = (i === segmentPosition); const isPast = (i < segmentPosition); const isFuture = (i > segmentPosition); // Calcular horas transcurridas en este segmento let dayHoursElapsed = 0; if (isPast) { dayHoursElapsed = dayTotalHours; } else if (isToday) { if (i === 0) { // Hoy es el día de reinicio, parte inicio dayHoursElapsed = currentHour - resetHourDecimal; if (dayHoursElapsed < 0) dayHoursElapsed = 0; } else if (i === 7) { // Hoy es el día de reinicio, parte fin dayHoursElapsed = currentHour; } else { // Día normal dayHoursElapsed = currentHour; } } // Días futuros: dayHoursElapsed = 0 // Porcentaje de llenado del segmento (0-100%) const dayFillPercent = dayTotalHours > 0 ? (dayHoursElapsed / dayTotalHours) * 100 : 0; // Uso asignado a este segmento (promedio distribuido) let usage = 0; if (dayHoursElapsed > 0 && hoursElapsed > 0) { usage = (dayHoursElapsed / hoursElapsed) * weeklyUsage; } // Estado basado en si el uso está dentro del ideal let status = 'pending'; if (dayHoursElapsed > 0) { const idealForThisSegment = (dayHoursElapsed / totalWeekHours) * 100; const actualForThisSegment = usage; status = actualForThisSegment <= idealForThisSegment * 1.1 ? 'good' : actualForThisSegment <= idealForThisSegment * 1.5 ? 'warning' : 'danger'; } // Offset visual (solo para el segmento de inicio del día de reinicio) const dayStartOffsetPercent = isResetDayStart ? (resetHourDecimal / 24) * 100 : 0; // Offset final (solo para el segmento de fin del día de reinicio) const dayEndOffsetPercent = isResetDayEnd ? ((24 - resetHourDecimal) / 24) * 100 : 0; dailyBreakdown.push({ dayIndex, dayName, dayFullName, isPast, isToday, isFuture, isResetDayStart, isResetDayEnd, usage, idealUsage: (dayTotalHours / totalWeekHours) * 100, dayFillPercent, dayHoursElapsed, dayTotalHours, dayStartOffsetPercent, dayEndOffsetPercent, status }); } // Calcular fecha estimada de fin del uso const usageRatePerHour = hoursElapsed > 0 ? weeklyUsage / hoursElapsed : 0; const hoursToReach100 = usageRatePerHour > 0 ? (100 - weeklyUsage) / usageRatePerHour : Infinity; const hoursUntilReset = totalWeekHours - hoursElapsed; let estimatedEndDate = null; let willRunOut = false; if (usageRatePerHour > 0 && hoursToReach100 < hoursUntilReset) { // Se acabará antes del reinicio willRunOut = true; estimatedEndDate = new Date(now.getTime() + hoursToReach100 * 60 * 60 * 1000); } else { // Llegará al reinicio con uso disponible estimatedEndDate = new Date(lastResetDate.getTime() + 7 * 24 * 60 * 60 * 1000); } // Determinar estado con más granularidad const diffPercent = weeklyUsage - idealUsedByNow; let status = 'perfect'; // Por debajo del ideal if (diffPercent > 0 && diffPercent <= 5) status = 'good'; // Hasta 5% por encima else if (diffPercent > 5 && diffPercent <= 15) status = 'warning'; // 5-15% por encima else if (diffPercent > 15) status = 'danger'; // Más del 15% por encima return { dailyBreakdown, idealDailyPercent, idealUsedByNow, hoursElapsed, avgUsagePerHour, idealUsagePerHour, hoursUntilReset, estimatedEndDate, willRunOut, hoursToReach100, status }; } function createWeeklyTracker(pageData, dailyData) { // Defense in depth: si pageData/dailyData vienen del caché de chrome.storage, // los Date pueden seguir siendo string/number aunque rehydrateDates haya pasado. // Normalizar aquí mismo evita TypeError al llamar .toLocaleDateString/Time. const ensureDate = (v) => { if (v == null) return null; if (v instanceof Date) return isNaN(v.getTime()) ? null : v; if (typeof v === 'string' || typeof v === 'number') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; } return null; }; pageData.sessionStartTime = ensureDate(pageData.sessionStartTime); pageData.sessionEndTime = ensureDate(pageData.sessionEndTime); dailyData.estimatedEndDate = ensureDate(dailyData.estimatedEndDate); const container = document.createElement('div'); container.className = 'claude-usage-tracker-container'; container.id = 'claude-usage-tracker'; const usagePercent = pageData.weeklyUsage; const resetDayName = DAYS_FULL_ES[pageData.resetDayIndex >= 0 ? pageData.resetDayIndex : 5] || 'próximo reinicio'; const boostStatus = getBoostStatus(); container.innerHTML = ` ${boostStatus.isActive ? `
${boostStatus.isBoostPeriod ? '⚡ 2×' : '1×'} ${boostStatus.isBoostPeriod ? 'Boost activo' : 'Horario normal'} ${boostStatus.isBoostPeriod ? '1× a las' : '2× a las'} ${boostStatus.nextChangeTime} (${boostStatus.nextChangeIn})
` : ''}
Uso semanal por días
${dailyData.dailyBreakdown.map(day => `
${day.dayFullName}: ${day.usage.toFixed(1)}% usado (${day.dayTotalHours.toFixed(1)}h) ${day.isToday ? ` — ${day.dayHoursElapsed.toFixed(1)}h transcurridas` : day.isPast ? ' — completo' : ''} ${day.isResetDayStart ? `
🔄 Desde reinicio (${pageData.resetHour})` : ''} ${day.isResetDayEnd ? `
⏳ Hasta reinicio (${pageData.resetHour})` : ''}
${day.isResetDayStart ? `
` : ''} ${day.isResetDayEnd ? `
` : ''}
${day.isResetDayStart ? day.dayName + '↓' : day.isResetDayEnd ? day.dayName + '↑' : day.dayName}
`).join('')}
${dailyData.status === 'perfect' ? '✓ Perfecto' : dailyData.status === 'good' ? '✓ Vas bien' : dailyData.status === 'warning' ? '⚠ Atención' : '✗ Excedido'}
Usado ${usagePercent.toFixed(1)}%
Ideal ${dailyData.idealUsedByNow.toFixed(1)}%
Diferencia ${usagePercent <= dailyData.idealUsedByNow ? '-' : '+'}${Math.abs(usagePercent - dailyData.idealUsedByNow).toFixed(1)}%
${dailyData.willRunOut ? '⚠ Se agotará el' : '📅 Reinicio el'} ${dailyData.estimatedEndDate ? dailyData.estimatedEndDate.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'short' }) : '--'} ${dailyData.estimatedEndDate ? dailyData.estimatedEndDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '--:--'} ${dailyData.willRunOut ? `(en ~${Math.round(dailyData.hoursToReach100)}h)` : ''}
Tiempo transcurrido ${Math.floor(dailyData.hoursElapsed)}h ${Math.round((dailyData.hoursElapsed % 1) * 60)}m
Uso/día real vs ideal ${(dailyData.avgUsagePerHour * 24).toFixed(2)}% / ${(dailyData.idealUsagePerHour * 24).toFixed(2)}%
Horas restantes ${Math.round((168 - dailyData.hoursElapsed))}h
Sesión actual
⏱ Tiempo
${Math.round(100 - (pageData.sessionTimeRemainingMin / (SESSION_DURATION_HOURS * 60)) * 100)}%
📊 Uso
${pageData.sessionUsage}%
Inicio ${pageData.sessionStartTime ? formatTime(pageData.sessionStartTime) : '--:--'}
Fin ${pageData.sessionEndTime ? formatTime(pageData.sessionEndTime) : '--:--'}
Restante ${pageData.sessionTimeRemainingMin > 0 ? Math.floor(pageData.sessionTimeRemainingMin / 60) + 'h ' + (pageData.sessionTimeRemainingMin % 60) + 'm' : '--'}
Reinicio semanal: ${resetDayName} a las ${pageData.resetHour} (en ${pageData.daysUntilReset} día${pageData.daysUntilReset !== 1 ? 's' : ''})
`; return container; } // =========================================================================== // 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) -------- // chrome.storage serializa con JSON: los Date salen como strings ISO. Al // cargar, rehidratamos los campos conocidos para que createWeeklyTracker // pueda llamar toLocaleDateString/Time sin TypeError. function rehydrateDates(cache) { if (!cache) return null; const toDate = (v) => { if (v == null) return null; if (v instanceof Date) return v; if (typeof v === 'string') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; } return v; }; if (cache.pageData) { cache.pageData.sessionStartTime = toDate(cache.pageData.sessionStartTime); cache.pageData.sessionEndTime = toDate(cache.pageData.sessionEndTime); } if (cache.dailyData) { cache.dailyData.estimatedEndDate = toDate(cache.dailyData.estimatedEndDate); } return cache; } function loadCache() { return new Promise(resolve => { try { chrome.storage.local.get([CACHE_KEY], result => resolve(rehydrateDates(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(); const dailyData = calculateDailyUsage(pageData); const tracker = createWeeklyTracker(pageData, dailyData); const anchor = findDialogInjectionAnchor(dialog); if (anchor && anchor.parentElement) { anchor.parentElement.prepend(tracker); } else { dialog.prepend(tracker); } saveCache(pageData, dailyData, 'manual'); updateFabState({ pageData, dailyData, scrapedAt: new Date().toISOString() }); console.log('[Claude Usage Tracker] Inyectado dentro del modal', { pageData }); return true; } // -------- Observers -------- function observeDialogForData(dialog, onReady) { if (dialogHasData(dialog)) { onReady(); return; } const obs = new MutationObserver(() => { if (dialogHasData(dialog)) { obs.disconnect(); onReady(); } }); obs.observe(dialog, { childList: 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() { try { const cache = await loadCache(); openOverlayPanel(cache); const ageMin = cache ? cacheAgeMin(cache.scrapedAt) : Infinity; if (ageMin > CACHE_TTL_MS / 60000) { refreshInBackground('overlay-stale'); } } catch (e) { console.error('[Claude Usage Tracker] FAB click error:', e); } } // -------- Overlay propio -------- function openOverlayPanel(cache) { try { 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(); }); const panel = document.createElement('div'); panel.className = 'claude-usage-tracker-overlay-panel'; 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')); 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); } panel.appendChild(header); panel.appendChild(body); overlay.appendChild(panel); document.body.appendChild(overlay); document.addEventListener('keydown', overlayEscapeHandler); } catch (e) { console.error('[Claude Usage Tracker] overlay error:', e); } } 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'); const hasTracker = body && body.querySelector('.claude-usage-tracker-container'); if (hasTracker) { // No destruir el tracker visible — mostrar aviso temporal en el header const header = ov.querySelector('.claude-usage-tracker-overlay-header'); if (header) { const existing = header.querySelector('.claude-usage-tracker-overlay-notice'); if (existing) existing.remove(); const notice = document.createElement('span'); notice.className = 'claude-usage-tracker-overlay-notice'; notice.textContent = msg; header.appendChild(notice); setTimeout(() => { if (notice.parentNode) notice.remove(); }, 7000); } } else if (body) { body.textContent = ''; const empty = document.createElement('div'); empty.className = 'claude-usage-tracker-overlay-empty'; empty.textContent = msg; body.appendChild(empty); } } // -------- 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); } // Exposición para depuración manual desde la consola window.__claudeUsageTracker = { version: 'debug-fab-click', loadCache, saveCache, openOverlayPanel, closeOverlayPanel, refreshInBackground, findUsageDialog, isUsageDialogOpen, parsePageData, injectFloatingButton, updateFabState, fabExists: () => !!document.getElementById(FAB_ID), overlayExists: () => !!document.getElementById(OVERLAY_ID), forceShowOverlay: async () => { const cache = await loadCache(); console.log('[CUT-DIAG forceShowOverlay] cache:', cache); openOverlayPanel(cache); return !!document.getElementById(OVERLAY_ID); } }; console.log('[CUT-DIAG] window.__claudeUsageTracker expuesto. Métodos:', Object.keys(window.__claudeUsageTracker).join(', ')); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap); } else { bootstrap(); } })();