Compare commits

..

6 Commits

Author SHA1 Message Date
Carlos Narro
699a8566d0 Simplificar a "FAB abre modal nativo" y añadir hora fin sesión
El usuario prefiere ver la página de Uso nativa de Claude en vez de un
overlay propio. Eso permite borrar mucho código:
- Fuera openOverlayPanel, closeOverlayPanel, setOverlaySpinner,
  renderOverlayWithCache, showOverlayError.
- Fuera refreshInBackground, tryOpenViaPushState/Hash/Click,
  closeUsageModal, waitForDialogWithData — ya no auto-abrimos en
  background.
- Fuera formatAge y CACHE_TTL_MS y OVERLAY_ID y refreshPromise.
- Fuera __claudeUsageTracker (era andamio de debug).
- Fuera bloque de CSS del overlay (~120 líneas).

Nuevo onFabClick: 3 intentos en cascada para abrir el modal nativo de
Claude (hash → pushState+popstate → click en "Uso"). Si los 3 fallan,
warn en consola y queda el manual.

Y siguiendo lo que pediste, en el resumen del tracker (el que se
inyecta dentro del modal) ahora aparece un bloque azul "⏱ Sesión
termina a las HH:MM (en Xh Ym)" justo debajo del bloque verde del
reinicio semanal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:04:46 +02:00
Carlos Narro
8b6c9dac59 FAB miniatura: mostrar usado/ideal sesión y semanal
Antes la miniatura mostraba solo "X% / Y%" (sesión / semanal). Ahora
muestra "sUsado%/sIdeal% ~ wUsado%/wIdeal%" para ver de un vistazo si
vas por encima o por debajo del ritmo proporcional al tiempo
transcurrido en la sesión y en la semana.

- sIdeal = % de los 5h de sesión ya transcurridos.
- wIdeal = idealUsedByNow (% de la semana ya transcurrida).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:00:30 +02:00
Carlos Narro
5ba8a7437a Defensa en createWeeklyTracker: normalizar Date in situ
El error TypeError: estimatedEndDate.toLocaleDateString seguía
ocurriendo a pesar de rehydrateDates al cargar caché. Sin entender
todavía por qué la rehidratación no convierte el valor (posible que
el campo en storage no sea ISO-string sino algo no contemplado),
añado defense in depth: createWeeklyTracker normaliza los tres campos
Date (sessionStart, sessionEnd, estimatedEndDate) en su primera línea.

Es no-op cuando rehydrateDates funciona (instanceof Date → as-is) y
rescata el render cuando falla por cualquier motivo. Una llamada a
toLocaleDateString sobre un string ya no rompe la extensión.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 15:47:03 +02:00
Carlos Narro
6e5f589156 Rehidratar Date al cargar caché y refresh no-destructivo
Bug raíz del click-no-abre-overlay: chrome.storage serializa con JSON,
los Date (sessionStartTime, sessionEndTime, estimatedEndDate) volvían
como strings ISO, y createWeeklyTracker llamaba .toLocaleDateString
en un string. rehydrateDates los devuelve a Date al cargar.

Mejoras adicionales:
- showOverlayError ya no destruye el panel si hay tracker visible:
  saca un aviso temporal en el header (auto-oculta a los 7s).
- DOM en el aviso construido con textContent, no innerHTML, para
  cerrar la puerta a XSS aunque el msg sea hardcodeado.
- Limpieza del andamio [CUT-DIAG] usado para encontrar el bug;
  __claudeUsageTracker se queda expuesto para depuración futura.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 14:45:00 +02:00
Carlos Narro
d2bc177376 Instrumentar FAB→overlay: logs [CUT-DIAG] y __claudeUsageTracker
El FAB se ve y muestra datos cacheados (47%/40%) pero el click no abre
el overlay. Añado:
- console.log en cada paso del camino click→cache→openOverlayPanel
- try/catch en onFabClick y openOverlayPanel para cazar fallos silenciosos
- pointerdown listener en FAB para diferenciar "click cancelado por
  Claude" de "handler nunca conectado"
- window.__claudeUsageTracker con loadCache, openOverlayPanel,
  forceShowOverlay, etc. para reproducir cada paso desde la consola

Cambios temporales: una vez identificada la causa se retiran.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 14:38:32 +02:00
Carlos Narro
673bd0bced Adaptar tracker al nuevo modal de Uso
Claude movió la página de uso de /settings/usage a un modal con hash
#settings/usage sobre cualquier URL, así que el content script no se
ejecutaba en ningún sitio. Tres flujos nuevos:

- FAB flotante en claude.ai/* con uso cacheado (X% / Y%); se oculta
  mientras el modal real está abierto.
- Inyección del tracker dentro del modal real cuando el usuario lo abre,
  anclando estructuralmente sobre la fila del primer progressbar
  (locale-proof) en vez de por texto.
- Overlay propio abierto desde el FAB con caché instantánea y refresh
  en background si TTL >5 min: intenta pushState+popstate, hash,
  click en "Uso" como fallbacks; cierra con botón X o Escape.

Cache en chrome.storage.local; se calienta cuando el usuario abre el
modal por su cuenta. Observers acotados y desconectados agresivamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 13:57:11 +02:00
3 changed files with 369 additions and 69 deletions

View File

@@ -492,6 +492,22 @@
} }
function createWeeklyTracker(pageData, dailyData) { 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'); const container = document.createElement('div');
container.className = 'claude-usage-tracker-container'; container.className = 'claude-usage-tracker-container';
container.id = 'claude-usage-tracker'; container.id = 'claude-usage-tracker';
@@ -571,6 +587,14 @@
${dailyData.willRunOut ? `<span class="estimated-end-hours">(en ~${Math.round(dailyData.hoursToReach100)}h)</span>` : ''} ${dailyData.willRunOut ? `<span class="estimated-end-hours">(en ~${Math.round(dailyData.hoursToReach100)}h)</span>` : ''}
</div> </div>
${pageData.sessionEndTime ? `
<div class="session-reset-info">
<span class="session-reset-info-label">⏱ Sesión termina</span>
<span class="session-reset-info-time">a las ${formatTime(pageData.sessionEndTime)}</span>
<span class="session-reset-info-remaining">(en ${Math.floor(pageData.sessionTimeRemainingMin / 60)}h ${pageData.sessionTimeRemainingMin % 60}m)</span>
</div>
` : ''}
<div class="usage-info-row"> <div class="usage-info-row">
<div class="usage-stat"> <div class="usage-stat">
<span class="usage-stat-label">Tiempo transcurrido</span> <span class="usage-stat-label">Tiempo transcurrido</span>
@@ -644,93 +668,289 @@
return container; return container;
} }
function injectTracker() { // ===========================================================================
// Remove existing tracker if present // Bootstrap nuevo (modal de Uso ahora es un <div role="dialog"> con hash
const existing = document.getElementById('claude-usage-tracker'); // #settings/usage sobre cualquier URL). Tres flujos:
if (existing) { // 1) Botón flotante (FAB) en todas las páginas de claude.ai.
existing.remove(); // 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 FAB_ID = 'claude-usage-tracker-fab';
const TRACKER_ID = 'claude-usage-tracker';
let dialogObserver = 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;
}
// -------- 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(); 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 dailyData = calculateDailyUsage(pageData);
const tracker = createWeeklyTracker(pageData, dailyData); const tracker = createWeeklyTracker(pageData, dailyData);
// Insertar justo DEBAJO del título principal h1 const anchor = findDialogInjectionAnchor(dialog);
const mainContent = document.querySelector('main'); if (anchor && anchor.parentElement) {
if (!mainContent) { anchor.parentElement.prepend(tracker);
console.log('[Claude Usage Tracker] No se encontró el main'); } 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 = `
<span class="claude-usage-tracker-fab-icon">📊</span>
<span class="claude-usage-tracker-fab-text">…</span>
`;
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 pd = cache.pageData;
const dd = cache.dailyData || {};
const sessionTotalMin = SESSION_DURATION_HOURS * 60;
// usado e ideal de SESIÓN
const sUsed = Math.round(pd.sessionUsage || 0);
const sIdeal = Math.max(0, Math.min(100,
Math.round(100 - ((pd.sessionTimeRemainingMin || 0) / sessionTotalMin) * 100)));
// usado e ideal SEMANAL
const wUsed = Math.round(pd.weeklyUsage || 0);
const wIdeal = Math.round(dd.idealUsedByNow || 0);
textEl.textContent = `${sUsed}%/${sIdeal}% ~ ${wUsed}%/${wIdeal}%`;
}
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');
}
// Click en el FAB: abrir el modal de Uso de Claude. Intenta varios métodos
// por si el router no escucha hashchange (Next.js puede colgar de popstate o
// de clicks en sus propios links). El usuario se conforma con ver el panel
// nativo, así que no abrimos nada propio.
async function onFabClick() {
try {
if (isUsageDialogOpen()) return;
// Método 1: setear hash
if (location.hash !== '#settings/usage') {
location.hash = '#settings/usage';
} else {
// ya estaba seteado pero el modal no está abierto: limpiar primero
location.hash = '';
await new Promise(r => setTimeout(r, 100));
location.hash = '#settings/usage';
}
await new Promise(r => setTimeout(r, 600));
if (isUsageDialogOpen()) return;
// Método 2: pushState + popstate
history.pushState(null, '', '#settings/usage');
window.dispatchEvent(new PopStateEvent('popstate'));
await new Promise(r => setTimeout(r, 600));
if (isUsageDialogOpen()) return;
// Método 3: clickar el enlace "Uso" si está visible
const usoBtn = [...document.querySelectorAll('a, button')]
.find(el => (el.innerText || '').trim() === 'Uso' && el.offsetParent !== null);
if (usoBtn) {
usoBtn.click();
return; return;
} }
// Buscar el h1 del título principal "Ajustes" (visible en móvil) console.warn('[Claude Usage Tracker] No he podido abrir el modal de Uso. Ábrelo manualmente desde el avatar.');
const mobileH1 = mainContent.querySelector('h1.font-heading'); } catch (e) {
console.error('[Claude Usage Tracker] FAB click error:', e);
if (mobileH1) {
// Insertar justo después del h1
mobileH1.after(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);
} }
} }
console.log('[Claude Usage Tracker] Inyectado en la parte superior', { // -------- Bootstrap --------
pageData, function bootstrap() {
dailyData injectFloatingButton();
startGlobalDialogWatcher();
window.addEventListener('hashchange', () => {
updateFabVisibility();
const dialog = findUsageDialog();
if (dialog) observeDialogForData(dialog, () => injectTrackerInDialog(dialog));
}); });
} window.addEventListener('popstate', updateFabVisibility);
// Wait for page to fully load // Sanity check: re-inyectar FAB si React lo eliminó
function waitForContent() {
const observer = new MutationObserver((mutations, obs) => {
const main = document.querySelector('main');
const hasContent = main && main.textContent.length > 100;
if (hasContent) {
obs.disconnect();
setTimeout(injectTracker, 500); // Small delay to ensure content is rendered
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Timeout fallback
setTimeout(() => {
observer.disconnect();
injectTracker();
}, 3000);
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForContent);
} else {
waitForContent();
}
// Update session time periodically
setInterval(() => { setInterval(() => {
const sessionValue = document.querySelector('.session-info-value'); injectFloatingButton();
if (sessionValue) { updateFabVisibility();
injectTracker(); }, 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();
} }
}, 60000); // Update every minute
})(); })();

View File

@@ -6,7 +6,7 @@
"permissions": ["storage"], "permissions": ["storage"],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["https://claude.ai/settings/usage*"], "matches": ["https://claude.ai/*"],
"js": ["content.js"], "js": ["content.js"],
"css": ["styles.css"], "css": ["styles.css"],
"run_at": "document_idle" "run_at": "document_idle"

View File

@@ -524,3 +524,83 @@
color: var(--text-300, #a3a3a3); color: var(--text-300, #a3a3a3);
font-size: 12px; 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;
}
/* ===========================================================================
Resumen — hora del próximo reinicio de sesión
=========================================================================== */
.session-reset-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 10px 14px;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
font-size: 13px;
}
.session-reset-info-label {
color: var(--text-300, #a3a3a3);
}
.session-reset-info-time {
font-weight: 600;
color: var(--text-100, #f5f5f5);
}
.session-reset-info-remaining {
color: var(--text-200, #d4d4d4);
font-weight: 500;
margin-left: auto;
}