diff --git a/README.md b/README.md index d4a76cd..d235005 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ ## 📖 Sobre o Projeto +**Study AI** é uma extensão para Chrome que implementa a técnica Pomodoro com recursos avançados de análise e personalização. O projeto foi desenvolvido com assistência de IA (GitHub Copilot), demonstrando como ferramentas de IA podem acelerar o desenvolvimento de software moderno. --- ## 📸 Demonstração Visual @@ -51,7 +52,7 @@ Aqui está uma prévia da interface e das funcionalidades principais da extensã ### 🎯 Filosofia de Desenvolvimento -Este projeto é um exemplo prático de **desenvolvimento assistido por IA**, onde: +Este projeto é um exemplo prático de desenvolvimento assistido por IA, onde: - ✅ A arquitetura foi planejada com auxílio de IA - ✅ Código otimizado e revisado por ferramentas inteligentes - ✅ Documentação técnica gerada de forma eficiente @@ -85,6 +86,17 @@ Este projeto é um exemplo prático de **desenvolvimento assistido por IA**, ond - Controles de reprodução direto no timer - Sincronização com suas playlists +### ⚡ **Quick Start Spotify** +1. Crie um app no Spotify Developer Dashboard. +2. Configure a Redirect URI da extensão (`chrome.identity.getRedirectURL()`). +3. Informe o Client ID em **Configurações > Spotify** no popup. +4. Clique em **Conectar Spotify** e autorize. +5. Abra o Spotify em algum dispositivo para usar play/pause/next/prev. + +Documentação completa: +- [docs/SPOTIFY_INTEGRATION.md](docs/SPOTIFY_INTEGRATION.md) +- [docs/MESSAGE_CONTRACT.md](docs/MESSAGE_CONTRACT.md) + --- ## 🚀 Tecnologias Utilizadas @@ -121,9 +133,9 @@ Este projeto é um exemplo prático de **desenvolvimento assistido por IA**, ond chrome://extensions/ ``` -3. **Ative o "Modo do desenvolvedor"** (toggle no canto superior direito) +3. **Ative o Modo do desenvolvedor** (toggle no canto superior direito) -4. **Clique em "Carregar sem compactação"** +4. **Clique em Carregar sem compactação** 5. **Selecione a pasta do projeto** (onde está o `manifest.json`) @@ -142,18 +154,18 @@ npm run build ### **1️⃣ Timer Básico** 1. Clique no ícone da extensão no Chrome 2. Selecione o modo desejado (Foco, Pausa Curta, Pausa Longa) -3. Clique em **"Iniciar"** +3. Clique em **Iniciar** 4. O timer continua rodando mesmo se fechar o popup! 5. Quando terminar, ouça a notificação sonora 🔔 ### **2️⃣ Estatísticas** -1. Clique na aba **"Estatísticas"** +1. Clique na aba **Estatísticas** 2. Veja seu gráfico semanal de produtividade 3. Analise distribuição por categoria de estudo 4. Exporte seus dados para backup (JSON) ### **3️⃣ Configurações** -1. Clique na aba **"Configurações"** +1. Clique na aba **Configurações** 2. **Tipo de Som:** Escolha entre Sparkle, Piano, Chime, Bell 3. **Volume:** Ajuste com o slider (0-100%) 4. **Testar Som:** Clique no botão 🔊 para preview @@ -221,7 +233,7 @@ study-ai/ ### **Áudio não toca?** 1. Verifique o volume do sistema (não está mudo?) 2. Ajuste o volume no slider da extensão -3. Teste com o botão 🔊 "Testar Som" +3. Teste com o botão 🔊 Testar Som 4. Console do offscreen deve mostrar: `[Offscreen] Teste de som: sparkle (volume: 70%)` ### **Estatísticas não aparecem?** @@ -257,7 +269,7 @@ Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) par ## 👨‍💻 Autor -Desenvolvido com 💙 e ☕ por **[Daniel Mourão Lopes]** +Desenvolvido com 💙 e ☕ por **Daniel Mourão Lopes** - GitHub: [@DanielMouraoti](https://github.com/DanielMouraoti) - LinkedIn: [Daniel Mourão](https://linkedin.com/in/daniel-mourão-backend) diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..166d9a9 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,13 @@ +{ + "productivityTitle": { "message": "📊 Productivity" }, + "languages": { "message": "Languages" }, + "music": { "message": "Music" }, + "noCategory": { "message": "No category" }, + "sunday": { "message": "Sun" }, + "monday": { "message": "Mon" }, + "tuesday": { "message": "Tue" }, + "wednesday":{ "message": "Wed" }, + "thursday": { "message": "Thu" }, + "friday": { "message": "Fri" }, + "saturday": { "message": "Sat" } +} diff --git a/_locales/pt_BR/messages.json b/_locales/pt_BR/messages.json new file mode 100644 index 0000000..dfc3f74 --- /dev/null +++ b/_locales/pt_BR/messages.json @@ -0,0 +1,13 @@ +{ + "productivityTitle": { "message": "📊 Produtividade" }, + "languages": { "message": "Idiomas" }, + "music": { "message": "Músicas" }, + "noCategory": { "message": "Sem Categoria" }, + "sunday": { "message": "Dom" }, + "monday": { "message": "Seg" }, + "tuesday": { "message": "Ter" }, + "wednesday":{ "message": "Qua" }, + "thursday": { "message": "Qui" }, + "friday": { "message": "Sex" }, + "saturday": { "message": "Sáb" } +} diff --git a/background.js b/background.js index c0b3c1a..30e4e1f 100644 --- a/background.js +++ b/background.js @@ -40,24 +40,35 @@ async function recordSessionCompletion() { const data = await chrome.storage.local.get(['weeklyStats','studySessions','currentCategory','completedSessions']); const weeklyStats = data.weeklyStats || {}; const studySessions = Array.isArray(data.studySessions) ? data.studySessions : []; - const currentCategory = data.currentCategory || 'Sem Categoria'; + const currentCategory = data.currentCategory || 'Outros'; const completed = (data.completedSessions || 0) + 1; // Incrementar contagem semanal weeklyStats[week] = weeklyStats[week] || { monday:0,tuesday:0,wednesday:0,thursday:0,friday:0,saturday:0,sunday:0 }; weeklyStats[week][day] = (weeklyStats[week][day] || 0) + 1; - // Registrar sessão - studySessions.push({ + // Calcular duração exata (do modo do timer) + const duration = MODES[timerState.mode] || MODES.focus; + + // Registrar sessão com detalhes completos + const session = { timestamp: now.toISOString(), - duration: MODES[timerState.mode] || MODES.focus, + date: now.toLocaleDateString('pt-BR'), + time: now.toLocaleTimeString('pt-BR'), + duration: duration, // em segundos mode: timerState.mode, - category: currentCategory - }); + category: currentCategory, + weekday: day, + weekNumber: week + }; + + console.log('[BG] 💾 Salvando sessão completa:', session); + studySessions.push(session); await chrome.storage.local.set({ weeklyStats, studySessions, currentWeek: week, completedSessions: completed }); + console.log('[BG] ✅ Sessão salva com sucesso! Total de sessões:', completed); } catch (e) { - console.warn('[BG] Falha ao registrar sessão:', e); + console.error('[BG] ❌ Falha ao registrar sessão:', e); } } @@ -269,70 +280,422 @@ async function playTestSound(soundType, volume) { } // ----- Spotify OAuth & API ----- +const manifest = chrome.runtime.getManifest(); +const SPOTIFY_SCOPES = [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing' +]; +const SPOTIFY_TOKEN_SAFETY_WINDOW_MS = 60 * 1000; +const SPOTIFY_CLIENT_ID_PLACEHOLDERS = ['YOUR_SPOTIFY_CLIENT_ID_HERE', '']; +const SPOTIFY_RATE_LIMIT_WAIT_CAP_SECONDS = 5; + const SPOTIFY_CONFIG = { - clientId: 'YOUR_SPOTIFY_CLIENT_ID_HERE', - redirectUrl: chrome.identity.getRedirectURL(''), + clientId: manifest?.oauth2?.client_id || 'YOUR_SPOTIFY_CLIENT_ID_HERE', authEndpoint: 'https://accounts.spotify.com/authorize', + tokenEndpoint: 'https://accounts.spotify.com/api/token', apiBase: 'https://api.spotify.com/v1' }; -async function getSpotifyToken() { +const spotifyRuntimeState = { + authState: null, + codeVerifier: null, + refreshInFlight: null, + lastRateLimitRetryAfter: null +}; + +function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getSpotifyRedirectUrl() { + return chrome.identity.getRedirectURL(); +} + +async function isSpotifyDebugEnabled() { + const cfg = await chrome.storage.local.get(['spotifyDebug']); + return !!cfg.spotifyDebug; +} + +async function spotifyLog(level, ...args) { + const enabled = await isSpotifyDebugEnabled(); + if (!enabled && level === 'debug') return; + const fn = level === 'error' ? console.error : (level === 'warn' ? console.warn : console.log); + fn('[Spotify]', ...args); +} + +function normalizeSpotifyClientId(value) { + return String(value || '').trim(); +} + +function isValidSpotifyClientId(clientId) { + const normalized = normalizeSpotifyClientId(clientId); + return normalized.length > 0 && !SPOTIFY_CLIENT_ID_PLACEHOLDERS.includes(normalized); +} + +function randomString(size = 32) { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +async function sha256Base64Url(input) { + const bytes = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-256', bytes); + const arr = new Uint8Array(digest); + let binary = ''; + arr.forEach(b => { binary += String.fromCharCode(b); }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function normalizeSpotifyError(error, fallbackMessage = 'Erro desconhecido no Spotify') { + const message = error?.userMessage || error?.message || fallbackMessage; + return { + message, + code: error?.code || 'spotify_error', + retryAfter: error?.retryAfter || null + }; +} + +function createSpotifyError(message, code = 'spotify_error', extras = {}) { + const err = new Error(message); + err.code = code; + Object.assign(err, extras); + return err; +} + +function buildSpotifyErrorResponse(error, fallbackMessage) { + const normalized = normalizeSpotifyError(error, fallbackMessage); + return { + success: false, + error: normalized.message, + code: normalized.code, + retryAfter: normalized.retryAfter + }; +} + +async function getSpotifyClientId() { + const localData = await chrome.storage.local.get(['spotifyClientId']); + const localClientId = normalizeSpotifyClientId(localData.spotifyClientId); + if (isValidSpotifyClientId(localClientId)) { + return { clientId: localClientId, configured: true, source: 'storage' }; + } + + const manifestClientId = normalizeSpotifyClientId(SPOTIFY_CONFIG.clientId); + if (isValidSpotifyClientId(manifestClientId)) { + return { clientId: manifestClientId, configured: true, source: 'manifest' }; + } + + return { clientId: null, configured: false, source: 'none' }; +} + +async function setSpotifyClientId(clientId) { + const normalized = normalizeSpotifyClientId(clientId); + if (!normalized) { + await chrome.storage.local.remove(['spotifyClientId']); + await clearSpotifyAuth(); + return getSpotifyClientId(); + } + + if (!isValidSpotifyClientId(normalized)) { + throw createSpotifyError('Client ID inválido. Verifique o valor no Spotify Dashboard.', 'invalid_client_id'); + } + + await chrome.storage.local.set({ spotifyClientId: normalized }); + await clearSpotifyAuth(); + return getSpotifyClientId(); +} + +async function clearSpotifyAuth() { return new Promise((resolve) => { - chrome.storage.local.get('spotifyToken', (data) => { - resolve(data.spotifyToken || null); - }); + chrome.storage.local.remove([ + 'spotifyToken', + 'spotifyTokenExpires', + 'spotifyRefreshToken', + 'spotifyTokenScope', + 'spotifyTokenType' + ], resolve); }); } -async function saveSpotifyToken(token, expiresIn = 3600) { - const expiresAt = Date.now() + (expiresIn * 1000); - return new Promise((resolve) => { - chrome.storage.local.set({ spotifyToken: token, spotifyTokenExpires: expiresAt }, resolve); +async function saveSpotifyTokens(tokenPayload) { + const expiresIn = Number.parseInt(tokenPayload.expires_in || '3600', 10); + const ttl = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600; + const expiresAt = Date.now() + (ttl * 1000); + + const current = await chrome.storage.local.get(['spotifyRefreshToken']); + const refreshTokenToSave = tokenPayload.refresh_token || current.spotifyRefreshToken || null; + + await chrome.storage.local.set({ + spotifyToken: tokenPayload.access_token, + spotifyTokenExpires: expiresAt, + spotifyRefreshToken: refreshTokenToSave, + spotifyTokenScope: tokenPayload.scope || null, + spotifyTokenType: tokenPayload.token_type || 'Bearer' }); + + return { + accessToken: tokenPayload.access_token, + expiresAt, + refreshToken: refreshTokenToSave + }; +} + +async function exchangeSpotifyCodeForToken({ code, clientId, redirectUri, codeVerifier }) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier + }); + + const response = await fetch(SPOTIFY_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch (_) { + payload = null; + } + + if (!response.ok || !payload?.access_token) { + const message = payload?.error_description || payload?.error || `Falha na troca de code por token (${response.status})`; + throw createSpotifyError(message, 'token_exchange_failed'); + } + + return payload; +} + +async function refreshSpotifyAccessToken() { + if (spotifyRuntimeState.refreshInFlight) { + return spotifyRuntimeState.refreshInFlight; + } + + spotifyRuntimeState.refreshInFlight = (async () => { + const cfg = await chrome.storage.local.get(['spotifyRefreshToken']); + const refreshToken = cfg.spotifyRefreshToken; + if (!refreshToken) { + await clearSpotifyAuth(); + throw createSpotifyError('Sessão Spotify expirada. Conecte novamente.', 'token_expired'); + } + + const client = await getSpotifyClientId(); + if (!client.configured) { + throw createSpotifyError('Spotify não configurado. Informe o Client ID nas configurações.', 'not_configured'); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: client.clientId + }); + + const response = await fetch(SPOTIFY_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch (_) { + payload = null; + } + + if (!response.ok || !payload?.access_token) { + await clearSpotifyAuth(); + const message = payload?.error_description || payload?.error || 'Falha ao atualizar token Spotify.'; + throw createSpotifyError(message, 'token_refresh_failed'); + } + + await saveSpotifyTokens(payload); + await spotifyLog('debug', 'Token Spotify atualizado via refresh token'); + return payload.access_token; + })(); + + try { + return await spotifyRuntimeState.refreshInFlight; + } finally { + spotifyRuntimeState.refreshInFlight = null; + } +} + +async function getSpotifyToken() { + const data = await chrome.storage.local.get(['spotifyToken', 'spotifyTokenExpires']); + const token = data.spotifyToken || null; + const expiresAt = data.spotifyTokenExpires || 0; + + if (!token) { + return null; + } + + const isExpiredOrNearExpiry = !expiresAt || (Date.now() + SPOTIFY_TOKEN_SAFETY_WINDOW_MS >= expiresAt); + if (!isExpiredOrNearExpiry) { + return token; + } + + try { + return await refreshSpotifyAccessToken(); + } catch (error) { + await spotifyLog('warn', 'Falha ao atualizar token automaticamente:', normalizeSpotifyError(error)); + await clearSpotifyAuth(); + return null; + } +} + +async function getSpotifyAuthStatus() { + const client = await getSpotifyClientId(); + const data = await chrome.storage.local.get(['spotifyToken', 'spotifyTokenExpires', 'spotifyRefreshToken']); + + const token = data.spotifyToken || null; + const expiresAtRaw = data.spotifyTokenExpires || null; + const refreshToken = data.spotifyRefreshToken || null; + const tokenExpired = !!token && (!expiresAtRaw || (Date.now() + SPOTIFY_TOKEN_SAFETY_WINDOW_MS >= expiresAtRaw)); + + if (tokenExpired) { + if (refreshToken) { + try { + await refreshSpotifyAccessToken(); + const refreshed = await chrome.storage.local.get(['spotifyTokenExpires']); + return { + connected: true, + expiresAt: refreshed.spotifyTokenExpires || null, + configured: client.configured, + clientIdSource: client.source, + tokenExpired: false + }; + } catch (_) { + await clearSpotifyAuth(); + } + } else { + await clearSpotifyAuth(); + } + } + + const expiresAt = tokenExpired ? null : (expiresAtRaw || null); + + return { + connected: !!token && !tokenExpired, + expiresAt, + configured: client.configured, + clientIdSource: client.source, + tokenExpired + }; +} + +function parseSpotifyAuthResponse(redirectUrl) { + const callbackUrl = new URL(redirectUrl); + const hash = callbackUrl.hash.startsWith('#') ? callbackUrl.hash.slice(1) : callbackUrl.hash; + const queryParams = callbackUrl.searchParams; + const hashParams = new URLSearchParams(hash); + + const authError = hashParams.get('error') || queryParams.get('error'); + const authErrorDescription = hashParams.get('error_description') || queryParams.get('error_description'); + + if (authError) { + throw createSpotifyError(authErrorDescription || `Falha na autenticação: ${authError}`, authError); + } + + const code = queryParams.get('code') || hashParams.get('code'); + const state = hashParams.get('state') || queryParams.get('state'); + + if (!code) { + throw createSpotifyError('Resposta de autenticação sem code.', 'missing_code'); + } + + return { + code, + state + }; } async function authenticateSpotify() { return new Promise((resolve, reject) => { - const scopes = ['user-read-playback-state', 'user-modify-playback-state', 'user-read-currently-playing']; - const authUrl = new URL(SPOTIFY_CONFIG.authEndpoint); - authUrl.searchParams.append('client_id', SPOTIFY_CONFIG.clientId); - authUrl.searchParams.append('response_type', 'token'); - authUrl.searchParams.append('redirect_uri', SPOTIFY_CONFIG.redirectUrl); - authUrl.searchParams.append('scope', scopes.join(' ')); - authUrl.searchParams.append('show_dialog', 'true'); - - chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true }, (redirectUrl) => { - if (chrome.runtime.lastError) { - console.error('[Spotify] Auth error:', chrome.runtime.lastError); - reject(chrome.runtime.lastError); - return; + (async () => { + const client = await getSpotifyClientId(); + if (!client.configured) { + throw createSpotifyError('Spotify não configurado. Informe o Client ID em Configurações.', 'not_configured'); } - if (!redirectUrl) { - reject(new Error('No redirect URL received')); - return; - } + const state = randomString(16); + const codeVerifier = randomString(64); + const codeChallenge = await sha256Base64Url(codeVerifier); - const url = new URL(redirectUrl); - const token = url.hash.match(/access_token=([^&]+)/)?.[1]; - const expiresIn = url.hash.match(/expires_in=([^&]+)/)?.[1]; + spotifyRuntimeState.authState = { value: state, createdAt: Date.now() }; + spotifyRuntimeState.codeVerifier = codeVerifier; - if (!token) { - reject(new Error('No access token in response')); - return; - } + const authUrl = new URL(SPOTIFY_CONFIG.authEndpoint); + authUrl.searchParams.append('client_id', client.clientId); + authUrl.searchParams.append('response_type', 'code'); + authUrl.searchParams.append('redirect_uri', getSpotifyRedirectUrl()); + authUrl.searchParams.append('scope', SPOTIFY_SCOPES.join(' ')); + authUrl.searchParams.append('show_dialog', 'true'); + authUrl.searchParams.append('state', state); + authUrl.searchParams.append('code_challenge_method', 'S256'); + authUrl.searchParams.append('code_challenge', codeChallenge); + + chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true }, async (redirectUrl) => { + try { + if (chrome.runtime.lastError) { + throw createSpotifyError(chrome.runtime.lastError.message || 'Falha ao abrir autenticação Spotify.', 'auth_flow_error'); + } + + if (!redirectUrl) { + throw createSpotifyError('Nenhuma URL de retorno recebida do Spotify.', 'missing_redirect_url'); + } + + const parsed = parseSpotifyAuthResponse(redirectUrl); + const expectedState = spotifyRuntimeState.authState?.value; + const expectedVerifier = spotifyRuntimeState.codeVerifier; + spotifyRuntimeState.authState = null; + spotifyRuntimeState.codeVerifier = null; + + if (!expectedState || parsed.state !== expectedState) { + throw createSpotifyError('Falha de segurança na autenticação (state inválido). Tente novamente.', 'invalid_state'); + } + + if (!expectedVerifier) { + throw createSpotifyError('Falha de segurança na autenticação (code_verifier ausente).', 'missing_code_verifier'); + } + + const tokenPayload = await exchangeSpotifyCodeForToken({ + code: parsed.code, + clientId: client.clientId, + redirectUri: getSpotifyRedirectUrl(), + codeVerifier: expectedVerifier + }); - saveSpotifyToken(token, parseInt(expiresIn) || 3600).then(() => { - console.log('[Spotify] Token autenticado e salvo'); - resolve({ success: true, token }); + await saveSpotifyTokens(tokenPayload); + const status = await getSpotifyAuthStatus(); + await spotifyLog('info', '✅ Token autenticado e salvo com sucesso'); + resolve({ success: true, ...status }); + } catch (callbackError) { + const normalized = normalizeSpotifyError(callbackError); + await spotifyLog('warn', '❌ Falha no callback OAuth:', normalized); + reject(createSpotifyError(normalized.message, normalized.code, { retryAfter: normalized.retryAfter })); + } }); - }); + })().catch(reject); }); } async function spotifyApiCall(endpoint, options = {}) { const token = await getSpotifyToken(); - if (!token) throw new Error('Spotify not authenticated'); + if (!token) { + throw createSpotifyError('Spotify não autenticado. Clique em "Conectar Spotify".', 'not_connected'); + } const response = await fetch(`${SPOTIFY_CONFIG.apiBase}${endpoint}`, { headers: { @@ -345,36 +708,217 @@ async function spotifyApiCall(endpoint, options = {}) { }); if (!response.ok) { + let apiMessage = ''; + try { + const errorBody = await response.json(); + apiMessage = errorBody?.error?.message || ''; + } catch (_) { + apiMessage = ''; + } + if (response.status === 401) { - chrome.storage.local.remove('spotifyToken'); - throw new Error('Token expirado'); + await clearSpotifyAuth(); + throw createSpotifyError('Sessão Spotify expirada. Conecte novamente.', 'token_expired'); } - throw new Error(`Spotify API error: ${response.status}`); + + if (response.status === 403) { + throw createSpotifyError(apiMessage || 'Permissão negada pelo Spotify. Verifique os escopos da aplicação.', 'insufficient_scope'); + } + + if (response.status === 404 && endpoint.startsWith('/me/player')) { + throw createSpotifyError('Nenhum dispositivo ativo no Spotify. Abra o Spotify em um dispositivo e tente novamente.', 'no_active_device'); + } + + if (response.status === 429) { + const retryAfter = Number.parseInt(response.headers.get('Retry-After') || '1', 10); + const safeRetryAfter = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 1; + spotifyRuntimeState.lastRateLimitRetryAfter = safeRetryAfter; + await wait(Math.min(safeRetryAfter, SPOTIFY_RATE_LIMIT_WAIT_CAP_SECONDS) * 1000); + throw createSpotifyError( + `Spotify temporariamente limitou as requisições. Tente novamente em ${safeRetryAfter}s.`, + 'rate_limited', + { retryAfter: safeRetryAfter } + ); + } + + throw createSpotifyError(apiMessage || `Spotify API error: ${response.status}`, 'spotify_api_error'); + } + + if (response.status === 204) return null; + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json(); } - return response.json(); + return response.text(); } -async function getCurrentTrack() { - try { - const data = await spotifyApiCall('/me/player/currently-playing'); - if (!data.item) return null; +async function getSpotifyPlaybackState() { + const authStatus = await getSpotifyAuthStatus(); + + if (!authStatus.configured) { + return { + success: true, + configured: false, + connected: false, + hasActiveDevice: false, + track: null, + message: 'Spotify não configurado.' + }; + } + + if (!authStatus.connected) { return { + success: true, + configured: true, + connected: false, + hasActiveDevice: false, + track: null, + message: authStatus.tokenExpired ? 'Token expirado. Conecte novamente.' : 'Não conectado ao Spotify.' + }; + } + + try { + // Fallback solicitado: tenta currently-playing primeiro, depois /me/player + let data = null; + try { + data = await spotifyApiCall('/me/player/currently-playing'); + } catch (firstError) { + const normalized = normalizeSpotifyError(firstError); + if (!['no_active_device', 'spotify_api_error'].includes(normalized.code)) { + throw firstError; + } + } + + if (!data || !data.item) { + data = await spotifyApiCall('/me/player'); + } + + if (!data) { + return { + success: true, + configured: true, + connected: true, + hasActiveDevice: false, + track: null, + message: 'Abra o Spotify em um dispositivo para controlar a reprodução.' + }; + } + + const track = data.item ? { name: data.item.name, artist: data.item.artists?.[0]?.name || 'Unknown', - isPlaying: data.is_playing, + isPlaying: !!data.is_playing, progress: data.progress_ms, duration: data.item.duration_ms + } : null; + + return { + success: true, + configured: true, + connected: true, + hasActiveDevice: !!data.device, + track, + message: track ? '' : 'Abra uma música no Spotify para exibir informações.' }; - } catch (e) { - console.warn('[Spotify] Erro ao obter música atual:', e); - return null; + } catch (error) { + const normalized = normalizeSpotifyError(error); + if (normalized.code === 'no_active_device') { + return { + success: true, + configured: true, + connected: true, + hasActiveDevice: false, + track: null, + message: 'Abra o Spotify em um dispositivo para controlar a reprodução.' + }; + } + + return { + success: false, + configured: authStatus.configured, + connected: authStatus.connected, + hasActiveDevice: false, + track: null, + ...buildSpotifyErrorResponse(error, 'Falha ao consultar estado de reprodução do Spotify.') + }; + } +} + +async function getCurrentTrack() { + const playback = await getSpotifyPlaybackState(); + if (!playback.success) { + throw createSpotifyError(playback.error || 'Falha ao obter música atual.', playback.code || 'spotify_playback_error'); + } + return playback.track; +} + +async function ensureSpotifyReadyForControl() { + const status = await getSpotifyAuthStatus(); + if (!status.configured) { + throw createSpotifyError('Spotify não configurado. Informe o Client ID nas configurações.', 'not_configured'); + } + + if (!status.connected) { + throw createSpotifyError(status.tokenExpired ? 'Token expirado. Conecte novamente.' : 'Spotify não conectado.', 'not_connected'); + } + + try { + const player = await spotifyApiCall('/me/player'); + if (!player || !player.device) { + throw createSpotifyError('Nenhum dispositivo ativo no Spotify.', 'no_active_device'); + } + return player; + } catch (error) { + const normalized = normalizeSpotifyError(error); + if (normalized.code === 'no_active_device') { + throw createSpotifyError('Nenhum dispositivo ativo no Spotify.', 'no_active_device'); + } + throw createSpotifyError(normalized.message, normalized.code, { retryAfter: normalized.retryAfter }); } } +async function getSpotifyConfig() { + const client = await getSpotifyClientId(); + const status = await getSpotifyAuthStatus(); + const cfg = await chrome.storage.local.get(['spotifyDebug']); + return { + configured: client.configured, + clientIdSource: client.source, + redirectUrl: getSpotifyRedirectUrl(), + connected: status.connected, + expiresAt: status.expiresAt, + debug: !!cfg.spotifyDebug + }; +} + +async function disconnectSpotify() { + await clearSpotifyAuth(); + return { success: true }; +} + +async function setSpotifyDebug(enabled) { + await chrome.storage.local.set({ spotifyDebug: !!enabled }); + return { success: true, debug: !!enabled }; +} + +async function openSpotifyWeb() { + return new Promise((resolve, reject) => { + chrome.tabs.create({ url: 'https://open.spotify.com' }, (tab) => { + if (chrome.runtime.lastError) { + reject(createSpotifyError(chrome.runtime.lastError.message || 'Falha ao abrir Spotify Web.', 'open_spotify_failed')); + return; + } + resolve({ success: true, tabId: tab?.id || null }); + }); + }); +} + async function playPause() { try { - const current = await spotifyApiCall('/me/player'); + const current = await ensureSpotifyReadyForControl(); + if (current.is_playing) { await spotifyApiCall('/me/player/pause', { method: 'PUT' }); } else { @@ -383,27 +927,29 @@ async function playPause() { return { success: true }; } catch (e) { console.error('[Spotify] Play/Pause error:', e); - return { success: false, error: e.message }; + return buildSpotifyErrorResponse(e, 'Falha ao alternar reprodução no Spotify.'); } } async function nextTrack() { try { + await ensureSpotifyReadyForControl(); await spotifyApiCall('/me/player/next', { method: 'POST' }); return { success: true }; } catch (e) { console.error('[Spotify] Next error:', e); - return { success: false, error: e.message }; + return buildSpotifyErrorResponse(e, 'Falha ao avançar faixa no Spotify.'); } } async function prevTrack() { try { + await ensureSpotifyReadyForControl(); await spotifyApiCall('/me/player/previous', { method: 'POST' }); return { success: true }; } catch (e) { console.error('[Spotify] Previous error:', e); - return { success: false, error: e.message }; + return buildSpotifyErrorResponse(e, 'Falha ao voltar faixa no Spotify.'); } } @@ -471,9 +1017,72 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'spotifyAuth') { authenticateSpotify().then(result => { - sendResponse({ success: true, message: 'Spotify autenticado com sucesso!' }); + sendResponse({ success: true, message: 'Spotify autenticado com sucesso!', ...result }); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha na autenticação Spotify.')); + }); + return true; + } + + if (message.action === 'getSpotifyConfig') { + getSpotifyConfig().then(config => { + sendResponse({ success: true, ...config }); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao carregar configuração Spotify.')); + }); + return true; + } + + if (message.action === 'setSpotifyClientId') { + setSpotifyClientId(message.clientId).then(config => { + sendResponse({ success: true, configured: config.configured, clientIdSource: config.source }); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao salvar Client ID do Spotify.')); + }); + return true; + } + + if (message.action === 'setSpotifyDebug') { + setSpotifyDebug(message.enabled).then(result => { + sendResponse(result); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao alterar modo debug Spotify.')); + }); + return true; + } + + if (message.action === 'openSpotifyWeb') { + openSpotifyWeb().then(result => { + sendResponse(result); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao abrir Spotify Web.')); + }); + return true; + } + + if (message.action === 'spotifyDisconnect') { + disconnectSpotify().then(result => { + sendResponse(result); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao desconectar Spotify.')); + }); + return true; + } + + if (message.action === 'spotifyStatus') { + getSpotifyAuthStatus().then(status => { + sendResponse({ success: true, ...status }); + }).catch(err => { + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao obter status Spotify.')); + }); + return true; + } + + if (message.action === 'getSpotifyPlaybackState') { + getSpotifyPlaybackState().then(state => { + sendResponse(state); }).catch(err => { - sendResponse({ success: false, error: err.message }); + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao obter estado de reprodução Spotify.')); }); return true; } @@ -482,34 +1091,34 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { getCurrentTrack().then(track => { sendResponse({ success: true, track }); }).catch(err => { - sendResponse({ success: false, error: err.message }); + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao obter faixa atual do Spotify.')); }); return true; } if (message.action === 'playPause') { playPause().then(result => { - sendResponse({ success: true }); + sendResponse(result); }).catch(err => { - sendResponse({ success: false, error: err.message }); + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao alternar reprodução no Spotify.')); }); return true; } if (message.action === 'nextTrack') { nextTrack().then(result => { - sendResponse({ success: true }); + sendResponse(result); }).catch(err => { - sendResponse({ success: false, error: err.message }); + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao avançar faixa no Spotify.')); }); return true; } if (message.action === 'prevTrack') { prevTrack().then(result => { - sendResponse({ success: true }); + sendResponse(result); }).catch(err => { - sendResponse({ success: false, error: err.message }); + sendResponse(buildSpotifyErrorResponse(err, 'Falha ao voltar faixa no Spotify.')); }); return true; } diff --git a/icon.png b/icon.png deleted file mode 100644 index e085d4f..0000000 Binary files a/icon.png and /dev/null differ diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..201b63d Binary files /dev/null and b/icons/icon128.png differ diff --git a/manifest.json b/manifest.json index 9666334..e8d7707 100644 --- a/manifest.json +++ b/manifest.json @@ -2,26 +2,32 @@ "manifest_version": 3, "name": "Study AI", "version": "1.0", + "default_locale": "pt_BR", "description": "Timer Pomodoro inteligente com checklist e persistência.", "icons": { - "16": "icon.png", - "48": "icon.png", - "128": "icon.png" + "16": "icons/icon128.png", + "48": "icons/icon128.png", + "128": "icons/icon128.png" }, "action": { - "default_popup": "popup.html", - "default_icon": "icon.png" + "default_icon": "icons/icon128.png", + "default_popup": "popup.html" }, "background": { "service_worker": "background.js" }, - "permissions": ["storage", "alarms", "offscreen", "identity"], + "permissions": ["storage", "alarms", "offscreen", "identity", "tabs"], "host_permissions": [ - "https://api.spotify.com/*" + "https://api.spotify.com/*", + "https://accounts.spotify.com/*", + "https://actions.google.com/*" ], "web_accessible_resources": [ { - "resources": ["offscreen.html"], + "resources": [ + "offscreen.html", + "assets/*" + ], "matches": [""] } ], @@ -29,7 +35,7 @@ "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.spotify.com https://accounts.spotify.com" }, "oauth2": { - "client_id": "YOUR_SPOTIFY_CLIENT_ID_HERE", + "client_id": "0d5322324d694b269925a5d3499b7321", "scopes": ["user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing"] } } \ No newline at end of file diff --git a/offscreen.js b/offscreen.js index 08500c2..2180d99 100644 --- a/offscreen.js +++ b/offscreen.js @@ -1,229 +1,245 @@ // Offscreen Document - Reprodução de Áudio (Manifest V3) -// Arquitetura oficial do Chrome para playback de áudio +// Sistema de Alerta Sintético com Web Audio API -console.log('[Offscreen] ✅ Documento carregado'); - -// Sons em Base64 (WAV mínimos válidos - 1 segundo de tom puro) -const SOUND_BASE64 = { - sparkle: 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAAAAA=', - piano: 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAAAAA=', - chime: 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAAAAA=', - bell: 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAAAAA=' -}; +console.log('[Offscreen] ✅ Documento carregado - Sistema de Alerta Sintético'); // Estado do AudioContext (compartilhado) let audioContextGlobal = null; -// Message Listener - PONTO DE ENTRADA -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - console.log('[Offscreen] 📨 Mensagem recebida:', JSON.stringify(message)); +// Obter ou criar AudioContext +function getAudioContext() { + if (!audioContextGlobal) { + audioContextGlobal = new (window.AudioContext || window.webkitAudioContext)(); + console.log('[Offscreen] 🆕 AudioContext criado'); + } - // Validar mensagem - if (!message || !message.action) { - console.error('[Offscreen] ❌ Mensagem inválida:', message); - sendResponse({ success: false, error: 'Mensagem inválida' }); - return true; + // Resumir se suspenso + if (audioContextGlobal.state === 'suspended') { + console.log('[Offscreen] ⏸️ AudioContext suspenso, tentando resume...'); + audioContextGlobal.resume(); } - // Processar ações de áudio - if (message.action === 'playTimerFinishedSound' || message.action === 'testSound') { - const soundType = message.soundType || 'sparkle'; - const volume = message.volume !== undefined ? message.volume : 70; + return audioContextGlobal; +} + +// 🎵 SONS SINTÉTICOS PREMIUM (3 segundos cada) + +// ✨ Sparkle: Sequência rápida de 3 tons agudos (tri-plim) +async function playSynthSparkle(ctx, volume) { + console.log('[Offscreen] ✨ Tocando Sparkle sintético'); + + const frequencies = [1047, 1319, 1568]; // C6, E6, G6 + const startTime = ctx.currentTime; + + frequencies.forEach((freq, index) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); - console.log(`[Offscreen] 🔊 Ação: ${message.action}`); - console.log(`[Offscreen] 🎵 Som: ${soundType}, Volume: ${volume}%`); + oscillator.frequency.value = freq; + oscillator.type = 'sine'; - // Tocar som de forma assíncrona - playSound(soundType, volume) - .then(() => { - console.log('[Offscreen] ✅ Som tocado com sucesso'); - sendResponse({ success: true }); - }) - .catch((error) => { - console.error('[Offscreen] ❌ Erro ao tocar som:', error); - sendResponse({ success: false, error: error.message }); - }); + const time = startTime + (index * 0.15); - return true; // Manter canal aberto para sendResponse assíncrono - } - - // Ação de desbloqueio (autorizar áudio) - if (message.action === 'unlockAudio') { - console.log('[Offscreen] 🔓 Desbloqueio de áudio solicitado'); - unlockAudio() - .then(() => { - console.log('[Offscreen] ✅ Áudio desbloqueado'); - sendResponse({ success: true }); - }) - .catch((error) => { - console.error('[Offscreen] ❌ Erro ao desbloquear:', error); - sendResponse({ success: false, error: error.message }); - }); - return true; - } - - console.warn('[Offscreen] ⚠️ Ação desconhecida:', message.action); - sendResponse({ success: false, error: 'Ação desconhecida' }); - return true; -}); + // Envelope suave + gainNode.gain.setValueAtTime(0, time); + gainNode.gain.linearRampToValueAtTime(volume * 0.4, time + 0.02); // Attack suave + gainNode.gain.exponentialRampToValueAtTime(0.01, time + 0.4); // Decay suave + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(time); + oscillator.stop(time + 0.4); + }); +} -// Função principal de playback -async function playSound(soundType, volumePercent) { - console.log(`[Offscreen] 🎼 playSound iniciado: ${soundType}, ${volumePercent}%`); +// 🎹 Piano: Acorde C-Major (Dó Maior) suave por 2 segundos +async function playSynthPiano(ctx, volume) { + console.log('[Offscreen] 🎹 Tocando Piano sintético'); - // Calcular volume (0.0 a 1.0) - const volume = Math.min(1, Math.max(0, volumePercent / 100)); - console.log(`[Offscreen] 📊 Volume calculado: ${volume}`); - - // Tentar método Base64 primeiro (mais confiável) - try { - await playSoundBase64(soundType, volume); - console.log(`[Offscreen] ✅ Base64 playback concluído`); - return; - } catch (error) { - console.warn(`[Offscreen] ⚠️ Base64 falhou, tentando síntese:`, error.message); - } + const frequencies = [261.63, 329.63, 392.00]; // C4, E4, G4 (Dó Maior) + const startTime = ctx.currentTime; + const duration = 2; - // Fallback: Web Audio API com síntese - try { - await playSoundSynthesis(soundType, volume); - console.log(`[Offscreen] ✅ Síntese playback concluído`); - } catch (error) { - console.error(`[Offscreen] ❌ Todos os métodos falharam:`, error); - throw error; - } + frequencies.forEach((freq) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.frequency.value = freq; + oscillator.type = 'triangle'; // Som mais suave que square + + // Envelope ADSR suave + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(volume * 0.3, startTime + 0.05); // Attack + gainNode.gain.linearRampToValueAtTime(volume * 0.25, startTime + 0.2); // Decay + gainNode.gain.setValueAtTime(volume * 0.25, startTime + duration - 0.5); // Sustain + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Release + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + duration); + }); } -// Método 1: Playback com Base64 (elemento Audio) -async function playSoundBase64(soundType, volume) { - console.log(`[Offscreen] 🎵 Tentando Base64 para: ${soundType}`); +// 🔔 Bell: Tom de sino clássico com decay longo +async function playSynthBell(ctx, volume) { + console.log('[Offscreen] 🔔 Tocando Bell sintético'); - const audioData = SOUND_BASE64[soundType] || SOUND_BASE64.sparkle; + const fundamentalFreq = 523.25; // C5 + const harmonics = [1, 2, 3, 4.2, 5.4]; // Harmônicos de sino + const startTime = ctx.currentTime; + const duration = 3; - return new Promise((resolve, reject) => { - const audio = new Audio(audioData); - audio.volume = volume; + harmonics.forEach((harmonic, index) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); - console.log(`[Offscreen] 📂 Audio criado, volume definido: ${volume}`); + oscillator.frequency.value = fundamentalFreq * harmonic; + oscillator.type = 'sine'; - // Evento de sucesso - audio.addEventListener('ended', () => { - console.log('[Offscreen] ✅ Áudio terminou de tocar'); - resolve(); - }, { once: true }); + const amplitude = volume * 0.2 * (1 / (index + 1)); // Harmônicos mais fracos - // Evento de erro - audio.addEventListener('error', (e) => { - console.error('[Offscreen] ❌ Erro no Audio:', e); - reject(new Error(`Audio error: ${e.message || 'Desconhecido'}`)); - }, { once: true }); + // Envelope com decay longo (característico de sino) + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(amplitude, startTime + 0.01); // Attack rápido + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Decay longo - // Tentar tocar - console.log('[Offscreen] ▶️ Chamando audio.play()...'); - audio.play() - .then(() => { - console.log('[Offscreen] ✅ play() resolvido com sucesso'); - }) - .catch((playError) => { - console.error('[Offscreen] ❌ play() rejeitado:', playError); - reject(playError); - }); + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); - // Timeout de segurança (3 segundos) - setTimeout(() => { - reject(new Error('Timeout: áudio não tocou em 3s')); - }, 3000); + oscillator.start(startTime); + oscillator.stop(startTime + duration); }); } -// Método 2: Síntese com Web Audio API -async function playSoundSynthesis(soundType, volume) { - console.log(`[Offscreen] 🎹 Tentando síntese para: ${soundType}`); +// 🎵 Chime: Dois tons alternados (alto/baixo) suaves +async function playSynthChime(ctx, volume) { + console.log('[Offscreen] 🎵 Tocando Chime sintético'); - // Obter ou criar AudioContext - if (!audioContextGlobal) { - audioContextGlobal = new (window.AudioContext || window.webkitAudioContext)(); - console.log('[Offscreen] 🆕 AudioContext criado'); - } - - const ctx = audioContextGlobal; - console.log(`[Offscreen] 🔊 AudioContext state: ${ctx.state}`); - - // Resumir se suspenso - if (ctx.state === 'suspended') { - console.log('[Offscreen] ⏸️ AudioContext suspenso, tentando resume...'); - await ctx.resume(); - console.log(`[Offscreen] ▶️ AudioContext resumed: ${ctx.state}`); - } + const frequencies = [880, 659.25]; // A5, E5 + const startTime = ctx.currentTime; - // Gerar som baseado no tipo - return new Promise((resolve) => { - const now = ctx.currentTime; - const duration = 0.5; - - // Criar nós + frequencies.forEach((freq, index) => { const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); - // Definir frequência baseada no tipo - const frequencies = { - sparkle: 880, // A5 - piano: 523, // C5 - chime: 1047, // C6 - bell: 392 // G4 - }; - - oscillator.frequency.value = frequencies[soundType] || 440; + oscillator.frequency.value = freq; oscillator.type = 'sine'; - console.log(`[Offscreen] 🎼 Frequência: ${oscillator.frequency.value}Hz`); + const time = startTime + (index * 0.6); - // Envelope ADSR - gainNode.gain.setValueAtTime(0, now); - gainNode.gain.linearRampToValueAtTime(volume, now + 0.01); // Attack - gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); // Decay + // Envelope suave + gainNode.gain.setValueAtTime(0, time); + gainNode.gain.linearRampToValueAtTime(volume * 0.35, time + 0.03); + gainNode.gain.exponentialRampToValueAtTime(0.01, time + 1.2); - // Conectar oscillator.connect(gainNode); gainNode.connect(ctx.destination); - // Tocar - oscillator.start(now); - oscillator.stop(now + duration); - - console.log('[Offscreen] 🎵 Oscilador iniciado'); + oscillator.start(time); + oscillator.stop(time + 1.2); + }); +} + +// 🎼 Função principal: Tocar som sintético por tipo +async function playSyntheticSound(soundType, volumePercent) { + console.log(`[Offscreen] 🎼 Iniciando som sintético: ${soundType} (${volumePercent}%)`); + + const ctx = getAudioContext(); + const volume = Math.min(1, Math.max(0, volumePercent / 100)); + + return new Promise((resolve) => { + // Selecionar função baseada no tipo + switch(soundType) { + case 'sparkle': + playSynthSparkle(ctx, volume); + break; + case 'piano': + playSynthPiano(ctx, volume); + break; + case 'bell': + playSynthBell(ctx, volume); + break; + case 'chime': + playSynthChime(ctx, volume); + break; + default: + console.warn(`[Offscreen] ⚠️ Tipo desconhecido: ${soundType}, usando sparkle`); + playSynthSparkle(ctx, volume); + } - // Resolver após duração + // Resolver após 3 segundos setTimeout(() => { - console.log('[Offscreen] ✅ Síntese concluída'); + console.log('[Offscreen] ✅ Som sintético concluído'); resolve(); - }, duration * 1000 + 100); + }, 3000); }); } -// Função de desbloqueio (chama play em silêncio) -async function unlockAudio() { - console.log('[Offscreen] 🔓 Desbloqueando áudio do navegador...'); - - try { - // Tocar som silencioso (volume 0) - const silentAudio = new Audio('data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAAAAAA='); - silentAudio.volume = 0.01; - await silentAudio.play(); - console.log('[Offscreen] ✅ Áudio desbloqueado via play silencioso'); - - // Criar AudioContext para desbloquear também - if (!audioContextGlobal) { - audioContextGlobal = new (window.AudioContext || window.webkitAudioContext)(); - } - if (audioContextGlobal.state === 'suspended') { - await audioContextGlobal.resume(); - } - console.log('[Offscreen] ✅ AudioContext desbloqueado'); +// Message Listener - PONTO DE ENTRADA +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Filtro: Ignorar silenciosamente ações que não são de áudio + if (!message || !message.action) { + return false; // Não processar + } + + // Ações filtradas (não geram log/erro - são tratadas por outros componentes) + const ignoredActions = ['getCurrentTrack', 'getTimerState', 'pollTimer']; + if (ignoredActions.includes(message.action)) { + return false; // Ignorar silenciosamente + } + + console.log('[Offscreen] 📨 Mensagem recebida:', JSON.stringify(message)); + + // Processar ações de áudio + if (message.action === 'playTimerFinishedSound' || message.action === 'testSound') { + const soundType = message.soundType || 'sparkle'; + const volume = message.volume !== undefined ? message.volume : 70; + + console.log(`[Offscreen] 🔊 Ação: ${message.action}, Som: ${soundType}, Volume: ${volume}%`); - } catch (error) { - console.warn('[Offscreen] ⚠️ Desbloqueio falhou (normal se já desbloqueado):', error.message); + // Tocar som sintético + playSyntheticSound(soundType, volume) + .then(() => { + console.log('[Offscreen] ✅ Som tocado com sucesso'); + sendResponse({ success: true }); + }) + .catch((error) => { + console.error('[Offscreen] ❌ Erro ao tocar som:', error); + sendResponse({ success: false, error: error.message }); + }); + + return true; // Manter canal aberto para sendResponse assíncrono } -} + + // Ação de desbloqueio (autorizar áudio) + if (message.action === 'unlockAudio') { + console.log('[Offscreen] 🔓 Desbloqueio de áudio solicitado'); + try { + const ctx = getAudioContext(); + // Criar silêncio de 1ms para desbloquear + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + gainNode.gain.value = 0.001; + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + oscillator.start(); + oscillator.stop(ctx.currentTime + 0.001); + + console.log('[Offscreen] ✅ Áudio desbloqueado'); + sendResponse({ success: true }); + } catch (error) { + console.error('[Offscreen] ❌ Erro ao desbloquear:', error); + sendResponse({ success: false, error: error.message }); + } + return true; + } + + console.warn('[Offscreen] ⚠️ Ação desconhecida:', message.action); + sendResponse({ success: false, error: 'Ação desconhecida' }); + return true; +}); -console.log('[Offscreen] 🚀 Sistema de áudio inicializado e pronto'); +console.log('[Offscreen] 🚀 Sistema de alerta sintético inicializado e pronto'); diff --git a/popup.html b/popup.html index 5c48ead..067a4ed 100644 --- a/popup.html +++ b/popup.html @@ -8,27 +8,37 @@
-

Study AI

-
- - -
+
Study AI Logo

Study AI

@@ -233,8 +289,8 @@
25:00
- - + +
@@ -242,6 +298,7 @@
Nenhuma música
+
Abra o Spotify em um dispositivo
@@ -251,23 +308,23 @@
- +
- +
-
Tema atual: Programação
+
Tema atual: Programação
@@ -275,22 +332,15 @@
-

📊 Dashboard

- -
-
- +

📊 Produtividade

- +
+
- - + +
@@ -300,39 +350,46 @@

📊 Dashboard

- -
- - -
+ +
- +
- +
- -
Não conectado
+ +
+ + +
+
+ +
+ + +
+
Não conectado
- +
diff --git a/popup.js b/popup.js index a2ceaf9..7e79a32 100644 --- a/popup.js +++ b/popup.js @@ -3,65 +3,125 @@ * Manifest V3 compatible with robust error handling */ (function() { - // ----- Traduções (PT-BR e EN) ----- + // ----- Cores Fixas por Matéria ----- + const MATERIA_COLORS = { + 'Python': '#3776ab', + 'JavaScript': '#f7df1e', + 'Cloud': '#ff9900', + 'DevOps': '#00add8', + 'Infra': '#2496ed', + 'Idiomas': '#8b5cf6', + 'Músicas': '#ec4899', + 'Musicas': '#ec4899', + 'Musica': '#ec4899', + 'Música': '#ec4899', + 'Sem Categoria': '#a0a8c1', + 'Default': '#8b5cf6' + }; + + function getSubjectColor(subject) { + return MATERIA_COLORS[subject] || MATERIA_COLORS['Default']; + } + + // ----- Traduções Completas (PT-BR e EN) ----- const TRANSLATIONS = { 'pt-BR': { - appTitle: 'Study AI', - focusTab: '🧠 Foco', - statsTab: '📈 Estatísticas', - settingsTab: '⚙️ Configurações', + // Abas + focusTab: 'Foco', + statsTab: 'Estatísticas', + settingsTab: 'Configurações', + + // Botões principais startBtn: 'Iniciar', pauseBtn: 'Pausar', resetBtn: 'Resetar', + + // Labels da aba Foco timerMode: 'Modo do Timer', studyTheme: 'Tema de Estudo', focus25m: 'Foco (25m)', shortBreak5m: 'Pausa Curta (5m)', longBreak15m: 'Pausa Longa (15m)', currentTheme: 'Tema atual:', - customizeTheme: 'Digite seu tema personalizado...', + customThemePlaceholder: 'Digite seu tema personalizado...', pressEnter: 'Pressione Enter para salvar', - settings: 'Configurações', - soundType: 'Tipo de Som', - volume: 'Volume', - testSound: '🔊 Testar', - theme: 'Tema', + customize: 'Personalizar…', + + // Temas de Estudo (categorias) + 'Programação': 'Programação', + 'Concursos': 'Concursos', + 'Idiomas': 'Idiomas', + 'Matemática': 'Matemática', + 'Leitura': 'Leitura', + + // Aba Estatísticas + dashboard: '📊 Dashboard', + productivityTitle: '📊 Produtividade', + week: 'Semana', + month: 'Mês', + year: 'Ano', + importJson: 'Importar JSON', + exportJson: 'Exportar JSON', + + // Aba Configurações + soundType: 'Som de Conclusão', + volume: 'Volume:', + language: 'Idioma', + theme: 'Tema:', darkMode: 'Escuro', lightMode: 'Claro', - language: 'Idioma', - portuguese: 'Português', - english: 'English', - weeklyStats: 'Estatísticas Semanais', - sessionsByCategory: 'Sessões por Categoria' + spotifyConnect: 'Conectar Spotify', + spotifyDisconnected: 'Não conectado', + spotifyConnected: '✅ Conectado' }, 'en': { - appTitle: 'Study AI', - focusTab: '🧠 Focus', - statsTab: '📈 Stats', - settingsTab: '⚙️ Settings', + // Tabs + focusTab: 'Focus', + statsTab: 'Stats', + settingsTab: 'Settings', + + // Main buttons startBtn: 'Start', pauseBtn: 'Pause', resetBtn: 'Reset', + + // Focus tab labels timerMode: 'Timer Mode', studyTheme: 'Study Theme', focus25m: 'Focus (25m)', shortBreak5m: 'Short Break (5m)', longBreak15m: 'Long Break (15m)', currentTheme: 'Current theme:', - customizeTheme: 'Enter your custom theme...', + customThemePlaceholder: 'Enter your custom theme...', pressEnter: 'Press Enter to save', - settings: 'Settings', - soundType: 'Sound Type', - volume: 'Volume', - testSound: '🔊 Test', - theme: 'Theme', + customize: 'Customize…', + + // Study Themes (categories) + 'Programação': 'Programming', + 'Concursos': 'Competitive Exams', + 'Idiomas': 'Languages', + 'Matemática': 'Mathematics', + 'Leitura': 'Reading', + + // Stats tab + dashboard: '📊 Dashboard', + productivityTitle: '📊 Productivity', + week: 'Week', + month: 'Month', + year: 'Year', + importJson: 'Import JSON', + exportJson: 'Export JSON', + + // Settings tab + soundType: 'Completion Sound', + volume: 'Volume:', + language: 'Language', + theme: 'Theme:', darkMode: 'Dark', lightMode: 'Light', - language: 'Language', - portuguese: 'Português', - english: 'English', - weeklyStats: 'Weekly Statistics', - sessionsByCategory: 'Sessions by Category' + spotifyConnect: 'Connect Spotify', + spotifyDisconnected: 'Not connected', + spotifyConnected: '✅ Connected' } }; @@ -80,12 +140,6 @@ charts: { weekly: null, category: null }, }; - // Tradução auxiliar - const t = (key) => { - const translations = TRANSLATIONS[State.language] || TRANSLATIONS['pt-BR']; - return translations[key] || key; - }; - // Utilities const $ = (id) => document.getElementById(id); const fmtTime = (s) => { @@ -103,87 +157,436 @@ }; const dayKey = (date) => ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][date.getDay()]; + // Labels i18n para dias da semana - sempre chamada dinamicamente + function getDayLabelsI18n() { + const keys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + if (chrome?.i18n) { + const translated = keys.map(key => chrome.i18n.getMessage(key)); + if (translated.every(Boolean)) { + console.log('[getDayLabelsI18n] 🌍 Dias traduzidos:', translated); + return translated; + } + } + // Fallback neutro em inglês para evitar prender no PT + const fallback = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + console.log('[getDayLabelsI18n] ⚠️ Usando fallback em EN:', fallback); + return fallback; + } + + // Traduz nomes de matérias se existir chave em messages.json + function translateSubjectName(subject) { + return chrome?.i18n?.getMessage(subject) || subject; + } + + // Traduz nomes de categorias armazenadas para keys i18n + function translateCategoryName(categoryName) { + // Mapeamento de categorias do banco de dados para chaves i18n + const categoryMap = { + 'Sem Categoria': 'noCategory', + 'Idiomas': 'languages', + 'Programação': 'Programação', + 'Concursos': 'Concursos', + 'Matemática': 'Matemática', + 'Leitura': 'Leitura', + 'Outros': 'noCategory' + }; + + const i18nKey = categoryMap[categoryName] || categoryName; + const translated = chrome?.i18n?.getMessage(i18nKey); + + if (translated) { + console.log(`[translateCategoryName] ✅ "${categoryName}" → "${translated}" (key: ${i18nKey})`); + return translated; + } + + // Fallback para tradução existente + const fallback = translateSubjectName(categoryName); + console.log(`[translateCategoryName] ⚠️ "${categoryName}" → "${fallback}" (fallback)`); + return fallback; + } + // Tab Navigation function setActiveTab(tab) { State.activeTab = tab; chrome.storage.local.set({ activeTab: tab }); document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === `tab-${tab}`)); + + // Quando aba Stats for ativada, renderizar métricas de estudo + if (tab === 'stats') { + console.log('[setActiveTab] 📈 Aba stats ativada, preparando renderização das métricas'); + // Aguardar aba ficar visível antes de renderizar + setTimeout(() => { + renderStudyMetrics(); + }, 50); // 50ms para aba ficar visível + } } - // ----- Sistema de Idiomas ----- - async function updateLanguage(lang) { - console.log(`[Popup] 🌐 Atualizando idioma para: ${lang}`); + // 🎨 FUNÇÃO PREMIUM: Renderizar Métricas de Estudo com barras empilhadas + async function renderStudyMetrics() { + console.log('[renderStudyMetrics] 📊 Iniciando renderização das métricas de estudo | Idioma:', State.language); + + // Tradução manual robusta do título + const isEnglish = State.language === 'en'; + const titleEl = document.getElementById('productivity-title') || document.querySelector('[data-i18n="productivityTitle"]'); + if (titleEl) { + titleEl.innerText = isEnglish ? '📊 Productivity' : '📊 Produtividade'; + console.log('[renderStudyMetrics] 📋 Título atualizado:', titleEl.innerText); + } + const container = document.getElementById('chart-container'); + const legendContainer = document.getElementById('legend-container'); + if (!container) { + console.error('[renderStudyMetrics] ❌ Container #chart-container não encontrado'); + return; + } + + try { + // Ler dados do storage + const data = await chrome.storage.local.get(['studySessions']); + const sessions = data.studySessions || []; + + console.log('[renderStudyMetrics] 📚 Sessões encontradas:', sessions.length); + + // Agrupar por dia da semana e matéria + const today = new Date(); + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); // Domingo da semana atual + weekStart.setHours(0, 0, 0, 0); + + // Tradução manual robusta dos dias da semana + const isEnglish = State.language === 'en'; + const labels = isEnglish + ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + : ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + console.log('[renderStudyMetrics] 📅 Labels de dias gerados:', labels, '| Idioma atual:', State.language); + const weekData = Array(7).fill(null).map(() => ({})); // Array de objetos {matéria: minutos} + const subjectsUsed = new Set(); + + // Processar sessões da última semana + sessions.forEach(session => { + const sessionDate = new Date(session.timestamp); + if (sessionDate >= weekStart) { + const dayIndex = sessionDate.getDay(); + const subject = session.category || 'Outros'; + const minutes = Math.floor((session.duration || 0) / 60); + + if (!weekData[dayIndex][subject]) { + weekData[dayIndex][subject] = 0; + } + weekData[dayIndex][subject] += minutes; + subjectsUsed.add(subject); + } + }); + + console.log('[renderStudyMetrics] 📈 Dados processados:', weekData); + + // Calcular altura máxima para escala + const maxMinutes = Math.max( + ...weekData.map(day => Object.values(day).reduce((sum, mins) => sum + mins, 0)), + 60 // Mínimo de 60 minutos (1h) para escala + ); + + // Limpar containers + container.innerHTML = ''; + if (legendContainer) legendContainer.innerHTML = ''; + + // Criar colunas empilhadas para cada dia + weekData.forEach((dayData, dayIndex) => { + const dayTotal = Object.values(dayData).reduce((sum, mins) => sum + mins, 0); + + // Wrapper da coluna + const columnWrapper = document.createElement('div'); + columnWrapper.style.cssText = ` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + position: relative; + `; + + // Container da barra empilhada + const stackedBar = document.createElement('div'); + stackedBar.style.cssText = ` + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + min-height: 5px; + cursor: pointer; + transition: all 200ms ease; + position: relative; + `; + + // Tooltip ao passar o mouse (criamos antes para ligar eventos dos segmentos) + const tooltip = document.createElement('div'); + tooltip.style.cssText = ` + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(26, 26, 46, 0.95); + color: #e9edf5; + padding: 6px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + z-index: 10; + border: 1px solid rgba(138, 43, 226, 0.5); + margin-bottom: 5px; + max-width: 200px; + white-space: pre-line; + `; + + // Se não há dados, mostrar barra vazia + if (dayTotal === 0) { + const emptyBar = document.createElement('div'); + emptyBar.style.cssText = ` + height: 5px; + background: rgba(100, 100, 100, 0.2); + border-radius: 4px; + `; + stackedBar.appendChild(emptyBar); + tooltip.textContent = 'Sem estudos'; + } else { + // Criar segmentos empilhados + const maxHeight = 180; + const totalHeight = (dayTotal / maxMinutes) * maxHeight; + + let currentHeight = 0; + Object.entries(dayData).forEach(([subject, minutes], index) => { + const segmentHeight = (minutes / dayTotal) * totalHeight; + const color = getSubjectColor(subject); + + const segment = document.createElement('div'); + segment.style.cssText = ` + height: ${segmentHeight}px; + background: ${color}; + ${index === 0 ? 'border-radius: 8px 8px 0 0;' : ''} + transition: all 200ms ease; + position: relative; + `; + segment.dataset.subject = subject; + segment.dataset.minutes = minutes; + + // Tooltip por segmento: mostra minutos exatos daquela matéria + segment.addEventListener('mouseenter', () => { + // Tradução manual robusta + const isEnglish = State.language === 'en'; + let translatedSubject = subject; + if (subject === 'Sem Categoria') { + translatedSubject = isEnglish ? 'No category' : 'Sem Categoria'; + } else if (subject === 'Idiomas') { + translatedSubject = isEnglish ? 'Languages' : 'Idiomas'; + } else if (subject === 'Músicas' || subject === 'Musicas' || subject === 'Musica' || subject === 'Música') { + translatedSubject = isEnglish ? 'Music' : subject; + } + tooltip.textContent = `${translatedSubject}: ${minutes}m`; + tooltip.style.opacity = '1'; + stackedBar.style.transform = 'scaleY(1.05)'; + stackedBar.style.filter = 'brightness(1.1)'; + }); + + segment.addEventListener('mouseleave', () => { + // não ocultamos aqui para permitir transição entre segmentos sem flicker + }); + + stackedBar.appendChild(segment); + currentHeight += segmentHeight; + }); + } + + stackedBar.addEventListener('mouseleave', () => { + tooltip.style.opacity = '0'; + stackedBar.style.transform = 'scaleY(1)'; + stackedBar.style.filter = 'brightness(1)'; + }); + + stackedBar.appendChild(tooltip); + + // Label do dia + const label = document.createElement('div'); + label.style.cssText = ` + color: var(--text); + font-size: 10px; + font-weight: 500; + text-align: center; + margin-top: 2px; + `; + label.textContent = labels[dayIndex]; + + // Efeitos de hover + stackedBar.addEventListener('mouseenter', () => { + stackedBar.style.transform = 'scaleY(1.05)'; + tooltip.style.opacity = '1'; + stackedBar.style.filter = 'brightness(1.1)'; + }); + + stackedBar.addEventListener('mouseleave', () => { + stackedBar.style.transform = 'scaleY(1)'; + tooltip.style.opacity = '0'; + stackedBar.style.filter = 'brightness(1)'; + }); + + columnWrapper.appendChild(stackedBar); + columnWrapper.appendChild(label); + container.appendChild(columnWrapper); + }); + + // Criar legenda + if (legendContainer && subjectsUsed.size > 0) { + Array.from(subjectsUsed).forEach(subject => { + const legendItem = document.createElement('div'); + legendItem.style.cssText = ` + display: flex; + align-items: center; + gap: 5px; + `; + + const colorBox = document.createElement('div'); + colorBox.style.cssText = ` + width: 12px; + height: 12px; + background: ${getSubjectColor(subject)}; + border-radius: 2px; + `; + + const subjectLabel = document.createElement('span'); + // Tradução manual robusta de categorias especiais + const isEnglish = State.language === 'en'; + let translated = subject; + if (subject === 'Sem Categoria') { + translated = isEnglish ? 'No category' : 'Sem Categoria'; + } else if (subject === 'Idiomas') { + translated = isEnglish ? 'Languages' : 'Idiomas'; + } else if (subject === 'Músicas' || subject === 'Musicas' || subject === 'Musica' || subject === 'Música') { + translated = isEnglish ? 'Music' : subject; + } + subjectLabel.textContent = translated; + subjectLabel.style.cssText = ` + color: var(--text); + font-weight: 500; + `; + + legendItem.appendChild(colorBox); + legendItem.appendChild(subjectLabel); + legendContainer.appendChild(legendItem); + }); + } + + console.log('[renderStudyMetrics] ✅ Métricas de estudo renderizadas com sucesso!'); + } catch (error) { + console.error('[renderStudyMetrics] ❌ ERRO ao criar métricas:', error); + } + } + + // ----- Sistema de Idiomas (Profissional - 100% Cobertura) ----- + async function updateLanguage(lang) { State.language = lang; await chrome.storage.local.set({ language: lang }); - // Atualizar abas - const tabButtons = document.querySelectorAll('.tab'); - const tabNames = { - 'focus': t('focusTab'), - 'stats': t('statsTab'), - 'settings': t('settingsTab') - }; - - tabButtons.forEach(btn => { - const tabName = btn.dataset.tab; - if (tabNames[tabName]) { - const span = btn.querySelector('span'); - if (span) span.textContent = tabNames[tabName].split(' ').slice(1).join(' '); + // 1. Atualizar todos os elementos com data-i18n + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + const translation = TRANSLATIONS[lang]?.[key]; + + if (!translation) return; + + // Para tabs, atualizar apenas o span interno + if (element.classList.contains('tab')) { + const span = element.querySelector('span'); + if (span) span.textContent = translation; + } else { + element.textContent = translation; } }); - // Atualizar botões - const btnStartPause = $('btn-start-pause'); - if (btnStartPause) btnStartPause.textContent = State.isRunning ? t('pauseBtn') : t('startBtn'); + // 2. Atualizar placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(element => { + const key = element.getAttribute('data-i18n-placeholder'); + const translation = TRANSLATIONS[lang]?.[key]; + if (translation) element.placeholder = translation; + }); - const btnReset = $('btn-reset'); - if (btnReset) btnReset.textContent = t('resetBtn'); + // 3. Atualizar opções dos selects (Timer Mode) + const modeSelect = $('modeSelect'); + if (modeSelect) { + modeSelect.querySelectorAll('option').forEach(opt => { + const key = opt.getAttribute('data-i18n'); + if (key && TRANSLATIONS[lang]?.[key]) { + opt.textContent = TRANSLATIONS[lang][key]; + } + }); + } - const testBtn = document.querySelector('[id*="test"]') || document.querySelector('button:contains("🔊")'); - // Atualizar labels - document.querySelectorAll('.hint').forEach(hint => { - const text = hint.textContent; - if (text.includes('Modo')) hint.textContent = t('timerMode'); - if (text.includes('Tema')) hint.textContent = t('studyTheme'); - if (text.includes('Som')) hint.textContent = t('soundType'); - if (text.includes('Volume')) hint.textContent = t('volume'); - }); + // 4. Atualizar opções do filtro de tempo (Stats) + const timeFilter = $('timeFilter'); + if (timeFilter) { + timeFilter.querySelectorAll('option').forEach(opt => { + const key = opt.getAttribute('data-i18n'); + if (key && TRANSLATIONS[lang]?.[key]) { + opt.textContent = TRANSLATIONS[lang][key]; + } + }); + } + + // 5. Atualizar label do tema (Dark/Light) baseado no estado atual + const themeLabel = $('themeLabel'); + if (themeLabel) { + themeLabel.textContent = State.theme === 'dark' + ? TRANSLATIONS[lang].darkMode + : TRANSLATIONS[lang].lightMode; + } - console.log(`[Popup] ✅ Idioma atualizado: ${lang}`); + // 6. IMPORTANTE: Atualizar botão Start/Pause baseado no estado do timer + const btnStartPause = $('btn-start-pause'); + if (btnStartPause) { + btnStartPause.textContent = State.isRunning + ? TRANSLATIONS[lang].pauseBtn + : TRANSLATIONS[lang].startBtn; + } + + // 7. Atualizar nome do tema atual exibido + const currentThemeElement = $('currentTheme'); + if (currentThemeElement && State.currentCategory) { + currentThemeElement.textContent = translateThemeName(State.currentCategory); + } + + // 8. Recarregar categorias para atualizar opções do select + await loadCategories(); + + // 9. Atualizar status Spotify no idioma atual + await updateSpotifyConnectionStatus(); } - function setupLanguageButtons() { - console.log('[Popup] 🌐 Configurando botões de idioma'); - - // Procurar por botões de bandeira (PT e EN) - const langBtns = document.querySelectorAll('[data-lang]'); - if (langBtns.length === 0) { - console.warn('[Popup] ⚠️ Nenhum botão de idioma encontrado'); + function setupLanguageListener() { + const languageSelect = $('languageSelect'); + if (!languageSelect) { + console.warn('[setupLanguageListener] ⚠️ Seletor de idioma não encontrado'); return; } - // Marcar botão ativo - langBtns.forEach(btn => { - if (btn.dataset.lang === State.language) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - - btn.addEventListener('click', async () => { - const lang = btn.dataset.lang; - - // Atualizar botões ativos - langBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - await updateLanguage(lang); - }); - }); + // Definir valor atual + languageSelect.value = State.language; + console.log('[setupLanguageListener] ✅ Listener de idioma configurado. Idioma atual:', State.language); - console.log(`[Popup] ✅ ${langBtns.length} botões de idioma configurados`); + // Listener de mudança - sempre re-renderizar métricas e gráfico + languageSelect.addEventListener('change', async (e) => { + const newLang = e.target.value; + console.log('[setupLanguageListener] 🌐 MUDANÇA DE IDIOMA DETECTADA:', State.language, '→', newLang); + await updateLanguage(newLang); + // Aguardar um tick para garantir que State.language foi atualizado + await new Promise(r => setTimeout(r, 50)); + // Re-renderizar métricas para atualizar dias, legendas e título instantaneamente + console.log('[setupLanguageListener] 🎨 Renderizando gráfico com novo idioma:', newLang); + renderStudyMetrics(); + }); } // Background Communication (with retry logic) @@ -194,16 +597,31 @@ chrome.runtime.sendMessage(message), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)) ]); - if (response && response.success) return response; + if (response) return response; if (attempt < maxRetries) await new Promise(r => setTimeout(r, 100 * attempt)); } catch (err) { if (attempt < maxRetries) await new Promise(r => setTimeout(r, 100 * attempt)); - if (attempt >= maxRetries) return null; + if (attempt >= maxRetries) { + return { success: false, error: 'Falha de comunicação com o serviço em background.' }; + } } } - return null; + return { success: false, error: 'Falha de comunicação com o serviço em background.' }; } + // Função auxiliar para atualizar texto do botão Start/Pause com tradução + function updateStartPauseButton(isRunning) { + const btnStartPause = $('btn-start-pause'); + if (btnStartPause) { + btnStartPause.textContent = isRunning + ? TRANSLATIONS[State.language].pauseBtn + : TRANSLATIONS[State.language].startBtn; + } + } + // Função auxiliar para traduzir nome de tema + function translateThemeName(themeName) { + return TRANSLATIONS[State.language]?.[themeName] || themeName; + } async function fullSync() { const resp = await sendMessage({ action: 'getTimerState' }); if (resp && resp.data) { @@ -211,7 +629,7 @@ State.isRunning = isRunning; State.currentMode = mode; $('timer-display').textContent = fmtTime(timeRemaining); - $('btn-start-pause').textContent = isRunning ? 'Pausar' : 'Iniciar'; + updateStartPauseButton(isRunning); $('modeSelect').value = mode; } } @@ -223,7 +641,7 @@ const { timeRemaining, isRunning, mode } = response.data; State.isRunning = isRunning; State.currentMode = mode; $('timer-display').textContent = fmtTime(timeRemaining); - $('btn-start-pause').textContent = isRunning ? 'Pausar' : 'Iniciar'; + updateStartPauseButton(isRunning); $('modeSelect').value = mode; } }); @@ -238,22 +656,31 @@ const themeSelect = $('themeSelect'); themeSelect.innerHTML = ''; const all = [...State.categoriesDefault, ...State.customCategories]; + + // Adicionar opções traduzidas all.forEach(cat => { const opt = document.createElement('option'); - opt.value = cat; opt.textContent = cat; themeSelect.appendChild(opt); + opt.value = cat; + opt.textContent = translateThemeName(cat); // Traduzir nome do tema + themeSelect.appendChild(opt); }); + + // Opção 'Personalizar' traduzida const customOpt = document.createElement('option'); - customOpt.value = '__custom__'; customOpt.textContent = 'Personalizar…'; + customOpt.value = '__custom__'; + customOpt.textContent = TRANSLATIONS[State.language].customize; themeSelect.appendChild(customOpt); if (all.includes(State.currentCategory)) themeSelect.value = State.currentCategory; else themeSelect.value = State.categoriesDefault[0]; - $('currentTheme').textContent = themeSelect.value; + + // Atualizar texto do tema atual traduzido + $('currentTheme').textContent = translateThemeName(themeSelect.value); } async function saveCategory(cat) { State.currentCategory = cat; - $('currentTheme').textContent = cat; + $('currentTheme').textContent = translateThemeName(cat); // Traduzir nome exibido await chrome.storage.local.set({ currentCategory: cat }); } @@ -267,15 +694,44 @@ } // ----- Charts (Chart.js) ----- + function destroyCharts() { + // Destruir instâncias antigas para evitar erro 'Canvas is already in use' + if (State.charts.weekly && typeof State.charts.weekly.destroy === 'function') { + try { + State.charts.weekly.destroy(); + console.log('[Charts] Gráfico semanal destruído'); + } catch (e) { + console.error('[Charts] Erro ao destruir gráfico semanal:', e); + } + State.charts.weekly = null; + } + if (State.charts.category && typeof State.charts.category.destroy === 'function') { + try { + State.charts.category.destroy(); + console.log('[Charts] Gráfico de categorias destruído'); + } catch (e) { + console.error('[Charts] Erro ao destruir gráfico de categorias:', e); + } + State.charts.category = null; + } + } + function ensureCharts() { const weeklyCanvas = $('weeklyChart'); const catCanvas = $('categoryChart'); - if (!weeklyCanvas || !catCanvas) return; // DOM ainda não pronto + if (!weeklyCanvas || !catCanvas) { + return false; + } const weeklyCtx = weeklyCanvas.getContext('2d'); const catCtx = catCanvas.getContext('2d'); - if (!weeklyCtx || !catCtx) return; // contexto indisponível + if (!weeklyCtx || !catCtx) { + return false; + } - if (!State.charts.weekly) { + // Destruir gráficos antigos se existirem + destroyCharts(); + + try { State.charts.weekly = new Chart(weeklyCtx, { type: 'bar', data: { @@ -284,62 +740,231 @@ }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }); - } - if (!State.charts.category) { State.charts.category = new Chart(catCtx, { type: 'doughnut', - data: { labels: [], datasets: [{ data: [], backgroundColor: ['#63b0ff','#8bd0ff','#2a7ef5','#f59e0b','#10b981','#ef4444','#a78bfa'] }] }, + data: { labels: ['Sem dados'], datasets: [{ data: [1], backgroundColor: ['#63b0ff','#8bd0ff','#2a7ef5','#f59e0b','#10b981','#ef4444','#a78bfa'] }] }, options: { responsive: true, maintainAspectRatio: false } }); + + console.log('[Charts] Gráficos criados com sucesso'); + return true; + } catch (error) { + console.error('[Charts] Erro ao criar gráficos:', error); + return false; } } - async function refreshCharts() { + // ----- Renderizar Gráfico Premium Flocus Style ----- + function renderChart(data) { try { - ensureCharts(); - if (!State.charts.weekly || !State.charts.weekly.data || !State.charts.weekly.data.datasets) return; - if (!State.charts.category || !State.charts.category.data || !State.charts.category.data.datasets) return; - - const { weeklyStats, currentWeek, studySessions } = await chrome.storage.local.get(['weeklyStats','currentWeek','studySessions']); - const order = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']; - const filter = document.getElementById('timeFilter').value || 'week'; - - let weeklyData; - if (filter === 'week') { - const weekKey = currentWeek || getISOWeek(new Date()); - const stats = weeklyStats && weeklyStats[weekKey] ? weeklyStats[weekKey] : { monday:0,tuesday:0,wednesday:0,thursday:0,friday:0,saturday:0,sunday:0 }; - weeklyData = order.map(k => stats[k] || 0); - } else { - const sessions = Array.isArray(studySessions) ? studySessions : []; - const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth(); - const agg = { monday:0,tuesday:0,wednesday:0,thursday:0,friday:0,saturday:0,sunday:0 }; - sessions.forEach(s => { - const d = new Date(s.timestamp); - if (filter === 'year' ? d.getFullYear() === year : (d.getFullYear() === year && d.getMonth() === month)) { - const key = dayKey(d); agg[key] = (agg[key] || 0) + 1; + const weeklyCanvas = $('weeklyChart'); + const catCanvas = $('categoryChart'); + if (!weeklyCanvas && !catCanvas) { + console.warn('[renderChart] ⚠️ Canvas não encontrado'); + return; + } + + const sessions = Array.isArray(data) ? data : []; + const isDark = State.theme === 'dark'; + const textColor = isDark ? '#e9edf5' : '#2c3e50'; + + // Verificar idioma atual para tradução manual robusta + const isEnglish = State.language === 'en'; + console.log('[renderChart] 🎨 Renderizando com idioma:', isEnglish ? 'EN' : 'PT-BR'); + + // Função auxiliar para traduzir categorias especiais de forma robusta + function translateCategory(category) { + try { + if (category === 'Sem Categoria') { + return isEnglish ? 'No category' : 'Sem Categoria'; + } + if (category === 'Idiomas') { + return isEnglish ? 'Languages' : 'Idiomas'; + } + // Aceitar todas as variações de Música (com/sem acento, singular/plural) + if (category === 'Músicas' || category === 'Musicas' || category === 'Musica' || category === 'Música') { + return isEnglish ? 'Music' : category; // Mantém a forma original em PT + } + return category; // Mantém categorias de usuário como estão + } catch (error) { + console.error('[translateCategory] Erro ao traduzir categoria:', error); + return category; // Fallback seguro + } + } + + // ===== GRÁFICO SEMANAL: LINE CHART COM DESIGN PREMIUM ===== + if (weeklyCanvas) { + // Destruir instância antiga + if (State.charts.weekly && typeof State.charts.weekly.destroy === 'function') { + State.charts.weekly.destroy(); + } + + // Gerar dados: 7 dias com mock se vazio + const today = new Date(); + const dayLabels = []; + let weeklyData = []; + + // Arrays fixos de dias traduzidos manualmente + const translatedDays = isEnglish + ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + : ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + + if (sessions.length === 0) { + // Mock data: 7 dias com horas aleatórias entre 2h e 9h + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dayOfWeek = d.getDay(); + dayLabels.push(translatedDays[dayOfWeek]); + weeklyData.push(Math.floor(Math.random() * 7) + 2); // 2-9 horas + } + } else { + // Usar dados reais: últimos 7 dias + const dayOrder = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + const agg = { sunday: 0, monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0 }; + sessions.forEach(s => { + const d = new Date(s.date); + const key = dayKey(d); + agg[key] = (agg[key] || 0) + 1; + }); + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dayOfWeek = d.getDay(); + dayLabels.push(translatedDays[dayOfWeek]); + const key = dayOrder[d.getDay()]; + weeklyData.push(agg[key] || 0); + } + } + + // Criar gradiente para preenchimento + const ctx = weeklyCanvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, 0, weeklyCanvas.height); + gradient.addColorStop(0, 'rgba(138, 43, 226, 0.3)'); + gradient.addColorStop(1, 'rgba(138, 43, 226, 0.01)'); + + State.charts.weekly = new Chart(ctx, { + type: 'line', + data: { + labels: dayLabels, + datasets: [{ + label: 'Horas de Estudo', + data: weeklyData, + borderColor: 'rgba(138, 43, 226, 1)', + backgroundColor: gradient, + borderWidth: 2.5, + fill: true, + tension: 0.4, + pointRadius: 4, + pointBackgroundColor: 'rgba(138, 43, 226, 1)', + pointBorderColor: isDark ? '#1a1a2e' : '#ffffff', + pointBorderWidth: 2, + pointHoverRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: isDark ? 'rgba(26, 26, 46, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: textColor, + bodyColor: textColor, + borderColor: 'rgba(138, 43, 226, 0.5)', + borderWidth: 1, + padding: 10, + displayColors: false, + callbacks: { + label: (ctx) => `${ctx.parsed.y}h de estudo` + } + } + }, + scales: { + x: { + display: true, + ticks: { color: textColor, font: { size: 11 } }, + grid: { display: false } + }, + y: { + display: true, + beginAtZero: true, + ticks: { color: textColor, font: { size: 11 } }, + grid: { display: false } + } + } } }); - weeklyData = order.map(k => agg[k] || 0); } - State.charts.weekly.data.datasets[0].data = weeklyData; - State.charts.weekly.update(); - const sessions = Array.isArray(studySessions) ? studySessions : []; - const counts = {}; - sessions.forEach(s => { const c = s.category || 'Sem Categoria'; counts[c] = (counts[c] || 0) + 1; }); - const labels = Object.keys(counts); - const values = labels.map(l => counts[l]); - State.charts.category.data.labels = labels; - State.charts.category.data.datasets[0].data = values; - State.charts.category.update(); - } catch (e) { - console.warn('[Popup] Falha ao atualizar charts:', e); + // ===== GRÁFICO DE CATEGORIAS: DOUGHNUT PREMIUM ===== + if (catCanvas) { + if (State.charts.category && typeof State.charts.category.destroy === 'function') { + State.charts.category.destroy(); + } + + const stats = { byTheme: {} }; + sessions.forEach(s => { + const theme = s.theme || 'Sem Categoria'; + stats.byTheme[theme] = (stats.byTheme[theme] || 0) + 1; + }); + + const themes = Object.keys(stats.byTheme); + const themeCounts = themes.map(t => stats.byTheme[t]); + // Traduzir labels das categorias especiais + const labels = themes.length > 0 ? themes.map(t => translateCategory(t)) : ['Sem dados']; + const values = themes.length > 0 ? themeCounts : [1]; + const colors = ['rgba(138, 43, 226, 0.8)', 'rgba(99, 176, 255, 0.8)', 'rgba(42, 126, 245, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(239, 68, 68, 0.8)', 'rgba(167, 139, 250, 0.8)']; + + State.charts.category = new Chart(catCanvas.getContext('2d'), { + type: 'doughnut', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: colors.slice(0, labels.length), + borderColor: isDark ? '#1a1a2e' : '#ffffff', + borderWidth: 2 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: textColor, font: { size: 11 }, padding: 12 }, + position: 'bottom' + }, + tooltip: { + backgroundColor: isDark ? 'rgba(26, 26, 46, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: textColor, + bodyColor: textColor, + borderColor: 'rgba(138, 43, 226, 0.5)', + borderWidth: 1, + callbacks: { + label: (ctx) => { + const value = ctx.parsed || 0; + return `${ctx.label}: ${value} sessão${value !== 1 ? 'ões' : ''}`; + } + } + } + } + } + }); + } + } catch (error) { + console.error('[renderChart] ❌ Erro ao renderizar gráfico:', error); + // Não propagar o erro para não quebrar a interface } } + async function refreshCharts() { + const { stats } = await chrome.storage.local.get(['stats']); + renderChart(Array.isArray(stats) ? stats : []); + } + + // ===== RENDERIZAÇÃO DE GRÁFICO CSS PURO ===== // ----- Import/Export ----- async function exportJson() { const data = await chrome.storage.local.get(['studySessions','weeklyStats','currentWeek','completedSessions','soundType','volume','currentCategory','theme','language','customCategories']); @@ -393,23 +1018,345 @@ } // ----- Spotify Panel ----- - async function updateSpotifyPanel() { + let spotifyPollIntervalId = null; + let spotifyUpdateInFlight = false; + let spotifyLastUpdateAt = 0; + const SPOTIFY_MIN_REFRESH_GAP_MS = 1500; + + function getSpotifyTexts() { + const isEnglish = State.language === 'en'; + return { + notConfigured: isEnglish ? 'Not configured' : 'Não configurado', + connected: isEnglish ? 'Connected' : 'Conectado', + disconnected: isEnglish ? 'Not connected' : 'Não conectado', + tokenExpired: isEnglish ? 'Token expired' : 'Token expirado', + noDevice: isEnglish ? 'Open Spotify on a device' : 'Abra o Spotify em um dispositivo', + noTrack: isEnglish ? 'No track playing' : 'Nenhuma música tocando', + unknownArtist: isEnglish ? 'Unknown' : 'Desconhecido', + saveClientIdOk: isEnglish ? 'Client ID saved' : 'Client ID salvo', + sourceStorage: isEnglish ? 'source: popup config' : 'fonte: configuração no popup', + sourceManifest: isEnglish ? 'source: manifest fallback' : 'fonte: fallback do manifest', + sourceNone: isEnglish ? 'source: not set' : 'fonte: não configurada', + redirectLabel: 'Redirect URI:', + reconnectHint: isEnglish ? 'Click Connect Spotify' : 'Clique em Conectar Spotify', + connectInProgress: isEnglish ? 'Connecting…' : 'Conectando…' + }; + } + + function formatSpotifyExpiry(expiresAt) { + if (!expiresAt) return ''; try { - const response = await sendMessage({ action: 'getCurrentTrack' }); - if (response && response.track) { - const track = response.track; - $('spotify-track-name').textContent = track.name || 'Nenhuma música'; - $('spotify-artist-name').textContent = track.artist || 'Unknown'; - $('spotify-panel').style.display = 'block'; - $('spotify-play').textContent = track.isPlaying ? '⏸️' : '▶️'; + return new Date(expiresAt).toLocaleTimeString(State.language === 'en' ? 'en-US' : 'pt-BR', { + hour: '2-digit', + minute: '2-digit' + }); + } catch (_) { + return ''; + } + } + + function setSpotifyControlsDisabled(disabled) { + ['btn-spotify-prev', 'btn-spotify-play', 'btn-spotify-next'].forEach(id => { + const button = $(id); + if (button) button.disabled = !!disabled; + }); + } + + function setSpotifyPanelVisible(visible) { + const panelEl = $('spotify-panel'); + if (panelEl) panelEl.style.display = visible ? 'block' : 'none'; + } + + function setSpotifyPanelMessage(message) { + const panelMsgEl = $('spotify-panel-message'); + if (!panelMsgEl) return; + panelMsgEl.textContent = message || ''; + panelMsgEl.style.display = message ? 'block' : 'none'; + } + + async function loadSpotifyConfigIntoUI() { + const input = $('spotify-client-id'); + const redirectEl = $('spotify-redirect-url'); + if (!input || !redirectEl) return; + + const resp = await sendMessage({ action: 'getSpotifyConfig' }); + if (!resp || !resp.success) { + redirectEl.textContent = ''; + return; + } + + const localData = await chrome.storage.local.get(['spotifyClientId']); + input.value = localData.spotifyClientId || ''; + + const txt = getSpotifyTexts(); + const sourceLabel = resp.clientIdSource === 'storage' + ? txt.sourceStorage + : (resp.clientIdSource === 'manifest' ? txt.sourceManifest : txt.sourceNone); + + redirectEl.textContent = `${txt.redirectLabel} ${resp.redirectUrl} (${sourceLabel})`; + } + + async function updateSpotifyConnectionStatus() { + const spotifyStatusEl = $('spotify-status'); + if (!spotifyStatusEl) return { configured: false, connected: false, tokenExpired: false, expiresAt: null }; + + const txt = getSpotifyTexts(); + const response = await sendMessage({ action: 'spotifyStatus' }); + if (!response || !response.success) { + spotifyStatusEl.textContent = `❌ ${response?.error || 'Erro no Spotify'}`; + spotifyStatusEl.style.color = '#ef4444'; + setSpotifyControlsDisabled(true); + return { configured: false, connected: false, tokenExpired: false, expiresAt: null }; + } + + const sourceText = response.clientIdSource === 'storage' + ? txt.sourceStorage + : (response.clientIdSource === 'manifest' ? txt.sourceManifest : txt.sourceNone); + + if (!response.configured) { + spotifyStatusEl.textContent = `⚠️ ${txt.notConfigured} · ${sourceText}`; + spotifyStatusEl.style.color = '#f59e0b'; + setSpotifyControlsDisabled(true); + return response; + } + + if (!response.connected) { + if (response.tokenExpired) { + spotifyStatusEl.textContent = `⚠️ ${txt.tokenExpired} · ${txt.reconnectHint}`; + spotifyStatusEl.style.color = '#f59e0b'; } else { - $('spotify-panel').style.display = 'none'; + spotifyStatusEl.textContent = `${txt.disconnected} · ${txt.reconnectHint}`; + spotifyStatusEl.style.color = 'var(--accent)'; } - } catch (e) { - console.warn('[Popup] Falha ao atualizar painel Spotify:', e); + setSpotifyControlsDisabled(true); + return response; } + + const exp = formatSpotifyExpiry(response.expiresAt); + spotifyStatusEl.textContent = exp + ? `✅ ${txt.connected} (expira ${exp})` + : `✅ ${txt.connected}`; + spotifyStatusEl.style.color = '#10b981'; + return response; + } + + async function updateSpotifyPanel() { + if (spotifyUpdateInFlight) return; + + const now = Date.now(); + if (now - spotifyLastUpdateAt < SPOTIFY_MIN_REFRESH_GAP_MS) return; + + spotifyUpdateInFlight = true; + spotifyLastUpdateAt = now; + + const trackNameEl = $('spotify-track-name'); + const artistNameEl = $('spotify-artist-name'); + const playBtnEl = $('btn-spotify-play'); + + if (!trackNameEl || !artistNameEl) { + spotifyUpdateInFlight = false; + return; + } + + try { + const txt = getSpotifyTexts(); + const status = await updateSpotifyConnectionStatus(); + + if (!status.configured || !status.connected) { + setSpotifyPanelVisible(false); + trackNameEl.textContent = txt.noTrack; + artistNameEl.textContent = ''; + setSpotifyPanelMessage(''); + setSpotifyControlsDisabled(true); + return; + } + + const playback = await sendMessage({ action: 'getSpotifyPlaybackState' }); + if (!playback || !playback.success) { + setSpotifyPanelVisible(true); + trackNameEl.textContent = txt.noTrack; + artistNameEl.textContent = txt.unknownArtist; + setSpotifyPanelMessage(playback?.error || 'Erro ao carregar Spotify'); + setSpotifyControlsDisabled(true); + return; + } + + setSpotifyPanelVisible(true); + + if (!playback.hasActiveDevice) { + trackNameEl.textContent = txt.noTrack; + artistNameEl.textContent = ''; + setSpotifyPanelMessage(playback.message || txt.noDevice); + setSpotifyControlsDisabled(true); + return; + } + + if (playback.track) { + trackNameEl.textContent = playback.track.name || txt.noTrack; + artistNameEl.textContent = playback.track.artist || txt.unknownArtist; + setSpotifyPanelMessage(''); + setSpotifyControlsDisabled(false); + if (playBtnEl) { + playBtnEl.textContent = playback.track.isPlaying ? '⏸️' : '▶️'; + } + return; + } + + trackNameEl.textContent = txt.noTrack; + artistNameEl.textContent = ''; + setSpotifyPanelMessage(playback.message || txt.noDevice); + setSpotifyControlsDisabled(true); + if (playBtnEl) playBtnEl.textContent = '▶️'; + } finally { + spotifyUpdateInFlight = false; + } + } + + async function forceUpdateSpotifyPanel() { + spotifyLastUpdateAt = 0; + await updateSpotifyPanel(); + } + + function startSpotifyPolling() { + if (spotifyPollIntervalId) clearInterval(spotifyPollIntervalId); + spotifyPollIntervalId = setInterval(() => { + updateSpotifyPanel(); + }, 5000); + } + + async function saveSpotifyClientIdFromInput() { + const input = $('spotify-client-id'); + const spotifyStatusEl = $('spotify-status'); + if (!input || !spotifyStatusEl) return; + + const txt = getSpotifyTexts(); + const clientId = (input.value || '').trim(); + const response = await sendMessage({ action: 'setSpotifyClientId', clientId }); + + if (!response || !response.success) { + spotifyStatusEl.textContent = `❌ ${response?.error || 'Falha ao salvar Client ID'}`; + spotifyStatusEl.style.color = '#ef4444'; + return; + } + + spotifyStatusEl.textContent = `✅ ${txt.saveClientIdOk}`; + spotifyStatusEl.style.color = '#10b981'; + await loadSpotifyConfigIntoUI(); + await updateSpotifyPanel(); + } + + // ----- Carregar Estatísticas de Estudo ----- + async function loadStudyStats() { + try { + const result = await chrome.storage.local.get(['stats']); + let sessions = Array.isArray(result.stats) ? result.stats : []; + console.log('Dados recuperados do storage:', sessions); + + // Se vazio, adicionar dados fictícios para demonstração + if (sessions.length === 0) { + const hasInitialData = await getValueFromStorage('hasInitialDemoData'); + if (!hasInitialData) { + sessions = generateDemoSessions(); + await chrome.storage.local.set({ + stats: sessions, + hasInitialDemoData: true + }); + } + } + + // Renderizar gráfico com os dados + renderChart(sessions); + + console.log('[Popup] Estatísticas carregadas:', sessions.length, 'sessões'); + } catch (error) { + console.error('[Popup] Erro ao carregar estatísticas:', error); + } + } + + // ----- Gerar Dados Fictícios de Demonstração ----- + function generateDemoSessions() { + const demos = []; + const themes = ['Python', 'JavaScript', 'Programação', 'Idiomas', 'Matemática']; + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + // Criar 15 sessões fictícias distribuídas nos últimos 15 dias + for (let i = 0; i < 15; i++) { + const timestamp = now - (i * oneDayMs) + Math.random() * 60000; + demos.push({ + date: new Date(timestamp).toISOString(), + duration: 25, + theme: themes[Math.floor(Math.random() * themes.length)], + mode: 'focus' + }); + } + + return demos; + } + + // ----- Processar Sessões para Gráficos ----- + function processSessions(sessions) { + const stats = { + byDay: { sunday: 0, monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0 }, + byTheme: {}, + total: 0, + totalMinutes: 0 + }; + + sessions.forEach(session => { + const date = new Date(session.date); + const day = dayKey(date); + + stats.byDay[day] += 1; + stats.byTheme[session.theme] = (stats.byTheme[session.theme] || 0) + 1; + stats.total += 1; + stats.totalMinutes += session.duration; + }); + + return stats; + } + + // ----- Atualizar Gráficos com Dados ----- + function updateChartsWithData(stats) { + try { + const weeklyCanvas = $('weeklyChart'); + const catCanvas = $('categoryChart'); + + if (!weeklyCanvas || !catCanvas) return; + + // Atualizar gráfico semanal + const dayOrder = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + const weeklyData = dayOrder.map(day => stats.byDay[day] || 0); + + if (State.charts.weekly) { + State.charts.weekly.data.datasets[0].data = weeklyData; + State.charts.weekly.update(); + } + + // Atualizar gráfico de categorias + const themes = Object.keys(stats.byTheme); + const themeCounts = themes.map(t => stats.byTheme[t]); + + if (State.charts.category) { + State.charts.category.data.labels = themes; + State.charts.category.data.datasets[0].data = themeCounts; + State.charts.category.update(); + } + } catch (error) { + console.error('[Popup] Erro ao atualizar gráficos:', error); + } + } + + // ----- Auxiliar para obter valor do storage ----- + async function getValueFromStorage(key) { + return new Promise((resolve) => { + chrome.storage.local.get([key], (data) => { + resolve(data[key]); + }); + }); } + // ----- Função de Teste: Salvar 5 Sessões Fake ----- // ----- Configurações ----- async function loadSettings() { const data = await chrome.storage.local.get(['soundType','volume','language','theme','activeTab']); @@ -419,7 +1366,7 @@ State.theme = data.theme || State.theme; State.activeTab = data.activeTab || State.activeTab; - $('soundSelect').value = State.soundType; + if ($('soundSelect')) $('soundSelect').value = State.soundType; $('volumeSlider').value = State.volume; $('volumeValue').textContent = State.volume; $('languageSelect').value = State.language; const isDark = State.theme === 'dark'; $('themeToggle').checked = isDark; $('themeLabel').textContent = isDark ? 'Escuro' : 'Claro'; @@ -429,121 +1376,246 @@ } function setupListeners() { + // Tabs document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => setActiveTab(btn.dataset.tab)); }); - $('btn-start-pause').addEventListener('click', async () => { - const action = State.isRunning ? 'pauseTimer' : 'startTimer'; - await sendMessage({ action }); - setTimeout(pollTimer, 100); - }); + // Timer controls - COM SEGURANÇA + if ($('btn-start-pause')) { + $('btn-start-pause').addEventListener('click', async () => { + const action = State.isRunning ? 'pauseTimer' : 'startTimer'; + await sendMessage({ action }); + setTimeout(pollTimer, 100); + }); + } - $('btn-reset').addEventListener('click', async () => { - await sendMessage({ action: 'resetTimer', mode: State.currentMode }); - setTimeout(pollTimer, 100); - }); + if ($('btn-reset')) { + $('btn-reset').addEventListener('click', async () => { + await sendMessage({ action: 'resetTimer', mode: State.currentMode }); + setTimeout(pollTimer, 100); + }); + } - $('modeSelect').addEventListener('change', async (e) => { - const mode = e.target.value; State.currentMode = mode; - await sendMessage({ action: 'setMode', mode }); - setTimeout(pollTimer, 100); - }); + if ($('modeSelect')) { + $('modeSelect').addEventListener('change', async (e) => { + const mode = e.target.value; State.currentMode = mode; + await sendMessage({ action: 'setMode', mode }); + setTimeout(pollTimer, 100); + }); + } - $('themeSelect').addEventListener('change', async (e) => { - if (e.target.value === '__custom__') { - $('customThemeRow').style.display = 'block'; $('customThemeInput').focus(); - } else { - $('customThemeRow').style.display = 'none'; await saveCategory(e.target.value); - } - }); + // Theme select + if ($('themeSelect')) { + $('themeSelect').addEventListener('change', async (e) => { + if (e.target.value === '__custom__') { + if ($('customThemeRow')) $('customThemeRow').style.display = 'block'; + if ($('customThemeInput')) $('customThemeInput').focus(); + } else { + if ($('customThemeRow')) $('customThemeRow').style.display = 'none'; + await saveCategory(e.target.value); + } + }); + } - $('customThemeInput').addEventListener('keypress', async (ev) => { - if (ev.key === 'Enter') { await addCustomCategory(ev.target.value.trim()); $('customThemeRow').style.display = 'none'; } - }); + if ($('customThemeInput')) { + $('customThemeInput').addEventListener('keypress', async (ev) => { + if (ev.key === 'Enter') { + await addCustomCategory(ev.target.value.trim()); + if ($('customThemeRow')) $('customThemeRow').style.display = 'none'; + } + }); + } - $('soundSelect').addEventListener('change', async (e) => { - State.soundType = e.target.value; await chrome.storage.local.set({ soundType: State.soundType }); - }); - - // Test sound button - if ($('btn-test-sound')) { - $('btn-test-sound').addEventListener('click', async () => { - const soundType = $('soundSelect').value; - const volume = parseInt($('volumeSlider').value, 10); - await sendMessage({ action: 'testSound', soundType, volume }); - }); - } - - $('volumeSlider').addEventListener('input', (e) => { $('volumeValue').textContent = e.target.value; }); - $('volumeSlider').addEventListener('change', async (e) => { - State.volume = parseInt(e.target.value, 10); - await chrome.storage.local.set({ volume: State.volume }); - // Auto-test sound when volume slider is released - const soundType = $('soundSelect').value; - await sendMessage({ action: 'testSound', soundType, volume: State.volume }); - }); - $('languageSelect').addEventListener('change', async (e) => { - State.language = e.target.value; await chrome.storage.local.set({ language: State.language }); - }); - - // Spotify listeners + // Sound select + if ($('soundSelect')) { + $('soundSelect').addEventListener('change', async (e) => { + State.soundType = e.target.value; + await chrome.storage.local.set({ soundType: State.soundType }); + const volume = parseInt($('volumeSlider')?.value || 70, 10); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume }); + }); + } + + // Volume slider + if ($('volumeSlider')) { + $('volumeSlider').addEventListener('input', (e) => { + if ($('volumeValue')) $('volumeValue').textContent = e.target.value; + }); + + let volumeDebounceTimer = null; + $('volumeSlider').addEventListener('input', async (e) => { + clearTimeout(volumeDebounceTimer); + volumeDebounceTimer = setTimeout(async () => { + const volume = parseInt(e.target.value, 10); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume }); + }, 300); + }); + + $('volumeSlider').addEventListener('change', async (e) => { + State.volume = parseInt(e.target.value, 10); + await chrome.storage.local.set({ volume: State.volume }); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume: State.volume }); + }); + } + + // Language select + if ($('languageSelect')) { + $('languageSelect').addEventListener('change', async (e) => { + const newLang = e.target.value; + State.language = newLang; + await chrome.storage.local.set({ language: State.language }); + + // Atualizar título imediatamente se estiver na aba Stats + const titleEl = document.getElementById('productivity-title'); + if (titleEl) { + const isEnglish = newLang === 'en'; + titleEl.innerText = isEnglish ? '📊 Productivity' : '📊 Produtividade'; + } + + // Re-renderizar gráfico se estiver na aba Stats + if (State.activeTab === 'stats') { + await new Promise(r => setTimeout(r, 50)); + renderStudyMetrics(); + } + }); + } + + // Spotify listeners - COM SEGURANÇA if ($('btn-spotify-connect')) { $('btn-spotify-connect').addEventListener('click', async () => { + const texts = getSpotifyTexts(); + if ($('spotify-status')) { + $('spotify-status').textContent = texts.connectInProgress; + $('spotify-status').style.color = 'var(--accent)'; + } const response = await sendMessage({ action: 'spotifyAuth' }); if (response && response.success) { - $('spotify-status').textContent = '✅ Conectado'; - $('spotify-status').style.color = '#10b981'; - $('spotify-panel').style.display = 'block'; + await forceUpdateSpotifyPanel(); } else { - $('spotify-status').textContent = '❌ Erro: ' + (response?.error || 'desconhecido'); + if ($('spotify-status')) { + $('spotify-status').textContent = '❌ Erro: ' + (response?.error || 'desconhecido'); + $('spotify-status').style.color = '#ef4444'; + } + } + }); + } + + if ($('btn-spotify-disconnect')) { + $('btn-spotify-disconnect').addEventListener('click', async () => { + const response = await sendMessage({ action: 'spotifyDisconnect' }); + if (!response?.success && $('spotify-status')) { + $('spotify-status').textContent = `❌ ${response?.error || 'Falha ao desconectar Spotify'}`; $('spotify-status').style.color = '#ef4444'; } + await forceUpdateSpotifyPanel(); + }); + } + + if ($('btn-open-spotify')) { + $('btn-open-spotify').addEventListener('click', async () => { + const response = await sendMessage({ action: 'openSpotifyWeb' }); + if (!response?.success && $('spotify-status')) { + $('spotify-status').textContent = `❌ ${response?.error || 'Falha ao abrir Spotify'}`; + $('spotify-status').style.color = '#ef4444'; + } + }); + } + + if ($('btn-spotify-client-save')) { + $('btn-spotify-client-save').addEventListener('click', async () => { + await saveSpotifyClientIdFromInput(); + }); + } + + if ($('spotify-client-id')) { + $('spotify-client-id').addEventListener('keydown', async (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + await saveSpotifyClientIdFromInput(); + } }); } if ($('btn-spotify-play')) { $('btn-spotify-play').addEventListener('click', async () => { - await sendMessage({ action: 'playPause' }); - updateSpotifyPanel(); + const response = await sendMessage({ action: 'playPause' }); + if (!response?.success && $('spotify-status')) { + $('spotify-status').textContent = `❌ ${response?.error || 'Falha no controle do Spotify'}`; + $('spotify-status').style.color = '#ef4444'; + } + await forceUpdateSpotifyPanel(); }); } if ($('btn-spotify-next')) { $('btn-spotify-next').addEventListener('click', async () => { - await sendMessage({ action: 'nextTrack' }); - updateSpotifyPanel(); + const response = await sendMessage({ action: 'nextTrack' }); + if (!response?.success && $('spotify-status')) { + $('spotify-status').textContent = `❌ ${response?.error || 'Falha no controle do Spotify'}`; + $('spotify-status').style.color = '#ef4444'; + } + await forceUpdateSpotifyPanel(); }); } if ($('btn-spotify-prev')) { $('btn-spotify-prev').addEventListener('click', async () => { - await sendMessage({ action: 'prevTrack' }); - updateSpotifyPanel(); + const response = await sendMessage({ action: 'prevTrack' }); + if (!response?.success && $('spotify-status')) { + $('spotify-status').textContent = `❌ ${response?.error || 'Falha no controle do Spotify'}`; + $('spotify-status').style.color = '#ef4444'; + } + await forceUpdateSpotifyPanel(); }); } - $('themeToggle').addEventListener('change', async (e) => { - const dark = e.target.checked; State.theme = dark ? 'dark' : 'light'; - $('themeLabel').textContent = dark ? 'Escuro' : 'Claro'; - document.body.classList.toggle('light', !dark); document.body.classList.toggle('dark', dark); - await chrome.storage.local.set({ theme: State.theme }); - }); + // Theme toggle + if ($('themeToggle')) { + $('themeToggle').addEventListener('change', async (e) => { + const dark = e.target.checked; + State.theme = dark ? 'dark' : 'light'; + if ($('themeLabel')) $('themeLabel').textContent = dark ? 'Escuro' : 'Claro'; + document.body.classList.toggle('light', !dark); + document.body.classList.toggle('dark', dark); + await chrome.storage.local.set({ theme: State.theme }); + }); + } - $('btn-export').addEventListener('click', exportJson); - $('btn-import').addEventListener('click', () => $('file-import').click()); - $('file-import').addEventListener('change', async (e) => { - const file = e.target.files?.[0]; if (file) await importJson(file); - e.target.value = ''; - }); + // Export/Import - COM SEGURANÇA + if ($('btn-export')) { + $('btn-export').addEventListener('click', exportJson); + } - document.getElementById('timeFilter').addEventListener('change', refreshCharts); + if ($('btn-import')) { + $('btn-import').addEventListener('click', () => { + if ($('file-import')) $('file-import').click(); + }); + } + if ($('file-import')) { + $('file-import').addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (file) await importJson(file); + e.target.value = ''; + }); + } + + // Storage listener chrome.storage.onChanged.addListener(async (changes, area) => { if (area !== 'local') return; - if (changes.weeklyStats || changes.studySessions || changes.currentWeek) await refreshCharts(); + if (changes.weeklyStats || changes.studySessions || changes.currentWeek || changes.stats) { + // Métricas foram atualizadas, renderizar novamente se estiver na aba stats + if (State.activeTab === 'stats') { + renderStudyMetrics(); + } + } if (changes.customCategories || changes.currentCategory) await loadCategories(); if (changes.timerState) pollTimer(); + if (changes.spotifyClientId || changes.spotifyToken || changes.spotifyTokenExpires) { + await loadSpotifyConfigIntoUI(); + await forceUpdateSpotifyPanel(); + } }); } @@ -553,17 +1625,14 @@ async function unlockAudio() { if (audioUnlocked) return; - console.log('[Popup] 🔓 Desbloqueando áudio...'); - try { // Enviar comando de desbloqueio para offscreen const response = await sendMessage({ action: 'unlockAudio' }); if (response && response.success) { audioUnlocked = true; - console.log('[Popup] ✅ Áudio desbloqueado com sucesso'); } } catch (e) { - console.warn('[Popup] ⚠️ Falha ao desbloquear (será tentado novamente):', e); + // Silenciar erro - será tentado novamente se necessário } } @@ -578,13 +1647,17 @@ document.addEventListener('click', unlockOnInteraction); document.addEventListener('keydown', unlockOnInteraction); - - console.log('[Popup] 🎧 Sistema de desbloqueio configurado'); } // ----- Bootstrap ----- document.addEventListener('DOMContentLoaded', async () => { - console.log('[Popup] 🚀 Inicializando...'); + console.log('[DOMContentLoaded] 🚀 Inicializando popup'); + + // 🔴 PRIORIDADE 1: Sincronizar estado do Timer do background + await fullSync(); + + // PRIORIDADE 2: Iniciar polling do Timer (mantém sincronizado) + setInterval(pollTimer, 200); // Carregar idioma armazenado const savedLang = await chrome.storage.local.get('language'); @@ -592,17 +1665,21 @@ State.language = savedLang.language; } - setupAudioUnlock(); // Configurar desbloqueio de áudio - setupLanguageButtons(); // Configurar botões de idioma + // Setup e listeners + setupAudioUnlock(); + setupLanguageListener(); await loadSettings(); await loadCategories(); setupListeners(); - await fullSync(); - await updateSpotifyPanel(); - try { await refreshCharts(); } catch(e) { console.warn('[Popup] refreshCharts falhou no bootstrap:', e); } - setInterval(pollTimer, 200); // 200ms para transição visual suave e sem delay - setInterval(updateSpotifyPanel, 5000); // Atualizar painel Spotify a cada 5s - console.log('[Popup] ✅ Inicialização completa'); + + // Traduções e UI + await updateLanguage(State.language); + await loadSpotifyConfigIntoUI(); + await updateSpotifyConnectionStatus(); + await forceUpdateSpotifyPanel(); + startSpotifyPolling(); + + console.log('[DOMContentLoaded] ✅ Popup inicializado'); }); })(); \ No newline at end of file diff --git a/study-ai-v1.0.0-stable/.gitignore b/study-ai-v1.0.0-stable/.gitignore new file mode 100644 index 0000000..56ed965 --- /dev/null +++ b/study-ai-v1.0.0-stable/.gitignore @@ -0,0 +1,127 @@ +# Arquivos de Sistema Operacional +.DS_Store +Thumbs.db +desktop.ini + +# Logs e Temporários +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Arquivos de Backup +*.backup +*.bak +*~ +*.swp +*.swo + +# Documentação de Desenvolvimento (não incluir no repositório) +# Mantemos apenas README.md e LICENSE +*.md +!README.md +!LICENSE.md +!.github/**/*.md + +# Arquivos de Teste e Debug +TESTE_*.md +TESTE_*.txt +DEBUG_*.md +DEBUG_*.txt + +# Documentação de Implementação/Histórico +IMPLEMENTACAO_*.md +CORRECOES_*.md +AUDIO_*.md +RESUME_*.md +RESUMO_*.md +FASE_*.md +PHASE_*.md +CHECKLIST_*.txt +GUIA_*.md +MANUAL_*.md + +# Scripts de Geração +generate_sounds.py + +# Nota: Os arquivos MP3 em assets/ devem ser gerados localmente +# Use generate_sounds.py para criar os sons ou baixe de fontes gratuitas + +# Arquivos Sensíveis (não commitar chaves/tokens) +.env +.env.local +secrets.json +credentials.json +*_SECRET_* + +# IDEs e Editores +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Node Modules (se adicionar build no futuro) +node_modules/ +dist/ +build/ + +# Arquivos de Configuração Local +config.local.js +settings.local.json + +# Chrome Extension Build +*.zip +*.crx +*.crx +*.pem +*.zip + +# Arquivos de Teste +test-results/ +coverage/ + +# Arquivos Grandes +*.mp4 +*.avi +*.mov +*.mkv + +# Documentação de Desenvolvimento (opcional, manter apenas o principal) +ARQUITETURA_TROCA_TURNO.md +AUDIO_FIX_SUMMARY.md +AUDIO_IMPLEMENTACAO_COMPLETA.md +AUDIO_QUICK_REFERENCE.md +CHECKLIST_IMPLEMENTACAO.txt +COMECE_AQUI.txt +CORRECOES_REALIZADAS.md +DASHBOARD_PREPARACAO.md +DOCUMENTATION_INDEX.md +FINAL_STATUS_REPORT.md +FLUXO_VISUAL.txt +GUIA_RAPIDO.md +GUIA_TESTE_ROBUSTO.md +GUIA_TESTES_V2.md +IMPLEMENTACAO_AUDIO_COMPLETA.md +IMPLEMENTACAO_FINALIZADA.txt +IMPLEMENTACAO_HISTORICO_SEMANAL.md +INDICE.txt +MANIFESTO.txt +NAVEGACAO_INTERFACE.md +PHASE_6_COMPLETION_SUMMARY.md +README_AUDIO_SYSTEM.md +RELEASE_NOTES_V3.md +RESUMO_AUDIO.md +RESUMO_FINAL.txt +RESUMO_MUDANCA_FLOCUS.txt +RESUMO_MUDANCAS.md +SOLUCAO_SYNC_ERROR.md +TESTE_AUDIO.txt +TESTE_AUDIO_RAPIDO.md +TESTE_CONEXAO.md +TESTE_PERSISTENCIA.txt +TEST_AND_VERIFY.md +VISUALIZACAO_HISTORICO_SEMANAL.md +background_novo.js +background.js.backup +exemplo-export.json +icon.png.jpg diff --git a/study-ai-v1.0.0-stable/LICENSE b/study-ai-v1.0.0-stable/LICENSE new file mode 100644 index 0000000..5573093 --- /dev/null +++ b/study-ai-v1.0.0-stable/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Mourão Lopes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/study-ai-v1.0.0-stable/README.md b/study-ai-v1.0.0-stable/README.md new file mode 100644 index 0000000..dfc1984 --- /dev/null +++ b/study-ai-v1.0.0-stable/README.md @@ -0,0 +1,249 @@ +# 🧠 Study AI - Timer Pomodoro Inteligente + +
+ +![Study AI](icon.png) + +**Um timer Pomodoro desenvolvido com auxílio de IA, focado em produtividade e análise de desempenho.** + +[![Chrome Extension](https://img.shields.io/badge/Chrome-Extension-4285F4?logo=googlechrome&logoColor=white)](https://github.com) +[![Manifest V3](https://img.shields.io/badge/Manifest-V3-green)](https://developer.chrome.com/docs/extensions/mv3/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +
+ +--- + +## 📖 Sobre o Projeto + +**Study AI** é uma extensão para Chrome que implementa a técnica Pomodoro com recursos avançados de análise e personalização. O projeto foi desenvolvido com assistência de IA (GitHub Copilot), demonstrando como ferramentas de IA podem acelerar o desenvolvimento de software moderno. + +### 🎯 Filosofia de Desenvolvimento + +Este projeto é um exemplo prático de desenvolvimento assistido por IA, onde: +- ✅ A arquitetura foi planejada com auxílio de IA +- ✅ Código otimizado e revisado por ferramentas inteligentes +- ✅ Documentação técnica gerada de forma eficiente +- ✅ Debugging acelerado com análise automatizada + +--- + +## ✨ Funcionalidades + +### ⏱️ **Timer Pomodoro Completo** +- **3 Modos de Estudo:** Foco (25 min), Pausa Curta (5 min), Pausa Longa (15 min) +- **Controles Intuitivos:** Iniciar, Pausar, Resetar com um clique +- **Persistência de Estado:** Timer continua rodando mesmo fechando o popup +- **Notificações Sonoras:** 4 tipos de sons personalizáveis (Sparkle, Piano, Chime, Bell) +- **Controle de Volume:** Ajuste fino de 0-100% com preview em tempo real + +### 📊 **Dashboard de Estatísticas** +- **Gráfico Semanal:** Visualize suas sessões de estudo por dia da semana +- **Análise por Categoria:** Distribua seu tempo entre diferentes áreas de estudo +- **Histórico Completo:** Acompanhe seu progresso ao longo do tempo +- **Export/Import de Dados:** Backup completo em formato JSON + +### 🎨 **Personalização** +- **Temas:** Modo claro e escuro +- **Categorias Customizáveis:** Crie categorias para organizar seus estudos +- **Sons de Notificação:** Escolha entre 4 sons com síntese Web Audio API +- **Interface Responsiva:** Design glassmorphism moderno e clean + +### 🎵 **Integração Spotify** *(em desenvolvimento)* +- Autenticação OAuth 2.0 +- Controles de reprodução direto no timer +- Sincronização com suas playlists + +--- + +## 🚀 Tecnologias Utilizadas + +### Frontend +- **HTML5 + CSS3:** Interface moderna com Glassmorphism +- **JavaScript (ES6+):** Lógica assíncrona e event-driven +- **Chart.js:** Visualização de dados interativa + +### Chrome APIs (Manifest V3) +- **Service Worker (background.js):** Timer persistente em background +- **Offscreen Documents:** Reprodução de áudio (Web Audio API) +- **Chrome Storage API:** Persistência local de dados +- **Chrome Identity API:** OAuth 2.0 para Spotify + +### Síntese de Áudio +- **Web Audio API:** Geração de tons com envelope ADSR +- **Offscreen Documents:** Compatibilidade com Manifest V3 + +--- + +## 📦 Instalação + +### **Método 1: Instalar como Extensão no Modo Desenvolvedor** + +1. **Clone o repositório:** + ```bash + git clone https://github.com/seu-usuario/study-ai.git + cd study-ai + ``` + +2. **Abra o Chrome e acesse:** + ``` + chrome://extensions/ + ``` + +3. **Ative o Modo do desenvolvedor** (toggle no canto superior direito) + +4. **Clique em Carregar sem compactação** + +5. **Selecione a pasta do projeto** (onde está o `manifest.json`) + +6. **Pronto!** A extensão aparecerá no canto superior direito do navegador 🎉 + +### **Método 2: Build para Produção** *(futuro)* +```bash +# Em breve: build automatizado +npm run build +``` + +--- + +## 🎮 Como Usar + +### **1️⃣ Timer Básico** +1. Clique no ícone da extensão no Chrome +2. Selecione o modo desejado (Foco, Pausa Curta, Pausa Longa) +3. Clique em **Iniciar** +4. O timer continua rodando mesmo se fechar o popup! +5. Quando terminar, ouça a notificação sonora 🔔 + +### **2️⃣ Estatísticas** +1. Clique na aba **Estatísticas** +2. Veja seu gráfico semanal de produtividade +3. Analise distribuição por categoria de estudo +4. Exporte seus dados para backup (JSON) + +### **3️⃣ Configurações** +1. Clique na aba **Configurações** +2. **Tipo de Som:** Escolha entre Sparkle, Piano, Chime, Bell +3. **Volume:** Ajuste com o slider (0-100%) +4. **Testar Som:** Clique no botão 🔊 para preview +5. **Tema:** Alterne entre claro/escuro +6. **Idioma:** Português ou Inglês + +--- + +## 📁 Estrutura do Projeto + +``` +study-ai/ +├── manifest.json # Configuração da extensão (MV3) +├── background.js # Service Worker (timer, storage, API) +├── popup.html # Interface principal (3 abas) +├── popup.js # Lógica do frontend +├── offscreen.html # Documento para áudio (MV3) +├── offscreen.js # Síntese de áudio (Web Audio API) +├── chart.js # Biblioteca Chart.js (local) +├── icon.png # Ícone da extensão +└── README.md # Este arquivo +``` + +--- + +## 🛠️ Desenvolvimento com IA + +### **Como a IA Ajudou neste Projeto:** + +1. **Arquitetura Inicial** + - IA sugeriu estrutura de Service Worker para MV3 + - Propôs padrão de mensagens entre popup ↔ background + - Definiu estratégia de persistência com Chrome Storage API + +2. **Implementação de Funcionalidades** + - **Timer:** Lógica de contagem regressiva com sincronização + - **Áudio:** Síntese Web Audio API com envelope ADSR + - **Gráficos:** Integração Chart.js com dados dinâmicos + - **Spotify OAuth:** Fluxo de autenticação completo + +3. **Debugging e Otimização** + - IA identificou problemas de message passing + - Corrigiu inconsistências de estado + - Otimizou performance do timer + +4. **Documentação** + - README profissional gerado + - Comentários de código claros + - Guias de teste criados + +### **Ferramentas de IA Utilizadas:** +- **GitHub Copilot:** Autocompletar código e sugestões contextuais +- **ChatGPT/Claude:** Planejamento de arquitetura e debugging +- **IA para Testes:** Geração de cenários de teste + +--- + +## 🐛 Troubleshooting + +### **Timer não inicia?** +- Verifique se a extensão está ativada em `chrome://extensions/` +- Recarregue a extensão clicando no ícone de reload +- Abra o console do background: Developer Tools → Service Worker + +### **Áudio não toca?** +1. Verifique o volume do sistema (não está mudo?) +2. Ajuste o volume no slider da extensão +3. Teste com o botão 🔊 Testar Som +4. Console do offscreen deve mostrar: `[Offscreen] Teste de som: sparkle (volume: 70%)` + +### **Estatísticas não aparecem?** +- Complete pelo menos uma sessão de estudo (25 min) +- Dados são salvos automaticamente ao final de cada sessão +- Export/Import para backup dos dados + +--- + +## 🤝 Contribuindo + +Contribuições são bem-vindas! Este projeto é open-source e aceita: + +1. **Reportar Bugs:** Abra uma issue descrevendo o problema +2. **Sugerir Funcionalidades:** Compartilhe suas ideias +3. **Pull Requests:** Fork → Branch → Commit → PR + +### **Roadmap Futuro:** +- [ ] Integração completa com Spotify +- [ ] Sincronização em nuvem (Google Drive) +- [ ] Notificações desktop nativas +- [ ] Suporte para múltiplos idiomas +- [ ] Mobile companion app +- [ ] Análise avançada com IA (previsão de produtividade) + +--- + +## 📄 Licença + +Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) para mais detalhes. + +--- + +## 👨‍💻 Autor + +Desenvolvido com 💙 e ☕ por **Daniel Mourão Lopes** + +- GitHub: [@DanielMouraoti](https://github.com/DanielMouraoti) +- LinkedIn: [Daniel Mourão](https://linkedin.com/in/daniel-mourão-backend) + +--- + +## 🙏 Agradecimentos + +- **GitHub Copilot** pela assistência no desenvolvimento +- **Comunidade Chrome Extensions** pela documentação +- **Chart.js** pela biblioteca de gráficos +- **Web Audio API** pela síntese de áudio + +--- + +
+ +**⭐ Se este projeto te ajudou, deixe uma estrela no repositório! ⭐** + +
diff --git a/study-ai-v1.0.0-stable/_locales/en/messages.json b/study-ai-v1.0.0-stable/_locales/en/messages.json new file mode 100644 index 0000000..166d9a9 --- /dev/null +++ b/study-ai-v1.0.0-stable/_locales/en/messages.json @@ -0,0 +1,13 @@ +{ + "productivityTitle": { "message": "📊 Productivity" }, + "languages": { "message": "Languages" }, + "music": { "message": "Music" }, + "noCategory": { "message": "No category" }, + "sunday": { "message": "Sun" }, + "monday": { "message": "Mon" }, + "tuesday": { "message": "Tue" }, + "wednesday":{ "message": "Wed" }, + "thursday": { "message": "Thu" }, + "friday": { "message": "Fri" }, + "saturday": { "message": "Sat" } +} diff --git a/study-ai-v1.0.0-stable/_locales/pt_BR/messages.json b/study-ai-v1.0.0-stable/_locales/pt_BR/messages.json new file mode 100644 index 0000000..dfc3f74 --- /dev/null +++ b/study-ai-v1.0.0-stable/_locales/pt_BR/messages.json @@ -0,0 +1,13 @@ +{ + "productivityTitle": { "message": "📊 Produtividade" }, + "languages": { "message": "Idiomas" }, + "music": { "message": "Músicas" }, + "noCategory": { "message": "Sem Categoria" }, + "sunday": { "message": "Dom" }, + "monday": { "message": "Seg" }, + "tuesday": { "message": "Ter" }, + "wednesday":{ "message": "Qua" }, + "thursday": { "message": "Qui" }, + "friday": { "message": "Sex" }, + "saturday": { "message": "Sáb" } +} diff --git a/study-ai-v1.0.0-stable/assets/README.md b/study-ai-v1.0.0-stable/assets/README.md new file mode 100644 index 0000000..58ece88 --- /dev/null +++ b/study-ai-v1.0.0-stable/assets/README.md @@ -0,0 +1,43 @@ +# 🔊 Pasta de Assets de Áudio + +Esta pasta contém os arquivos de som usados pela extensão. + +## 📁 Arquivos Necessários + +Você precisa adicionar os seguintes arquivos de som MP3 nesta pasta: + +- `sparkle.mp3` - Som agudo e cristalino (4 beeps ascendentes) +- `piano.mp3` - Acorde de piano suave +- `chime.mp3` - Som de sino/carrilhão +- `bell.mp3` - Som de sino grave + +## 🎵 Como Obter os Sons + +### Opção 1: Criar seus próprios sons +Use um software de áudio como Audacity para gravar ou sintetizar. + +### Opção 2: Usar sons gratuitos +Sites como: +- **Freesound.org** (CC0 license) +- **Zapsplat.com** (free tier) +- **Mixkit.co** (royalty-free) + +### Opção 3: Gerar com IA +Use ferramentas como: +- **ElevenLabs** (text-to-sound) +- **Mubert** (AI music generation) + +## ⚙️ Especificações Técnicas + +- **Formato:** MP3 (recomendado) ou WAV +- **Duração:** 0.5s a 2s +- **Tamanho:** Máximo 100KB por arquivo +- **Taxa de bits:** 128kbps é suficiente + +## 🔄 Fallback + +Se os arquivos MP3 não forem encontrados, a extensão usará síntese de áudio via Web Audio API como fallback. + +--- + +**Última atualização:** Janeiro 2026 diff --git a/study-ai-v1.0.0-stable/assets/bell.mp3 b/study-ai-v1.0.0-stable/assets/bell.mp3 new file mode 100644 index 0000000..3db14d6 --- /dev/null +++ b/study-ai-v1.0.0-stable/assets/bell.mp3 @@ -0,0 +1 @@ +ID3 diff --git a/study-ai-v1.0.0-stable/assets/chime.mp3 b/study-ai-v1.0.0-stable/assets/chime.mp3 new file mode 100644 index 0000000..3db14d6 --- /dev/null +++ b/study-ai-v1.0.0-stable/assets/chime.mp3 @@ -0,0 +1 @@ +ID3 diff --git a/study-ai-v1.0.0-stable/assets/piano.mp3 b/study-ai-v1.0.0-stable/assets/piano.mp3 new file mode 100644 index 0000000..3db14d6 --- /dev/null +++ b/study-ai-v1.0.0-stable/assets/piano.mp3 @@ -0,0 +1 @@ +ID3 diff --git a/study-ai-v1.0.0-stable/assets/sparkle.mp3 b/study-ai-v1.0.0-stable/assets/sparkle.mp3 new file mode 100644 index 0000000..3db14d6 --- /dev/null +++ b/study-ai-v1.0.0-stable/assets/sparkle.mp3 @@ -0,0 +1 @@ +ID3 diff --git a/study-ai-v1.0.0-stable/background.js b/study-ai-v1.0.0-stable/background.js new file mode 100644 index 0000000..fb1b191 --- /dev/null +++ b/study-ai-v1.0.0-stable/background.js @@ -0,0 +1,531 @@ +console.log('[Background] Service Worker inicializado'); + +// Configuração dos modos de timer (em segundos) +const MODES = { + focus: 25 * 60, + shortBreak: 5 * 60, + longBreak: 15 * 60 +}; + +let timerState = { + timeRemaining: MODES.focus, + isRunning: false, + mode: 'focus', + lastUpdated: Date.now(), + endTime: null // Timestamp absoluto de quando o timer deve acabar +}; + +let timerInterval = null; + +// Estatísticas - Helpers +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNum = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; +} + +function dayKey(date) { + return ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][date.getDay()]; +} + +async function recordSessionCompletion() { + try { + const now = new Date(); + const week = getISOWeek(now); + const day = dayKey(now); + + const data = await chrome.storage.local.get(['weeklyStats','studySessions','currentCategory','completedSessions']); + const weeklyStats = data.weeklyStats || {}; + const studySessions = Array.isArray(data.studySessions) ? data.studySessions : []; + const currentCategory = data.currentCategory || 'Outros'; + const completed = (data.completedSessions || 0) + 1; + + // Incrementar contagem semanal + weeklyStats[week] = weeklyStats[week] || { monday:0,tuesday:0,wednesday:0,thursday:0,friday:0,saturday:0,sunday:0 }; + weeklyStats[week][day] = (weeklyStats[week][day] || 0) + 1; + + // Calcular duração exata (do modo do timer) + const duration = MODES[timerState.mode] || MODES.focus; + + // Registrar sessão com detalhes completos + const session = { + timestamp: now.toISOString(), + date: now.toLocaleDateString('pt-BR'), + time: now.toLocaleTimeString('pt-BR'), + duration: duration, // em segundos + mode: timerState.mode, + category: currentCategory, + weekday: day, + weekNumber: week + }; + + console.log('[BG] 💾 Salvando sessão completa:', session); + studySessions.push(session); + + await chrome.storage.local.set({ weeklyStats, studySessions, currentWeek: week, completedSessions: completed }); + console.log('[BG] ✅ Sessão salva com sucesso! Total de sessões:', completed); + } catch (e) { + console.error('[BG] ❌ Falha ao registrar sessão:', e); + } +} + +// ----- Persistência ----- +async function loadState() { + return new Promise((resolve) => { + chrome.storage.local.get(['timerState'], (data) => { + if (data.timerState) { + timerState = { ...timerState, ...data.timerState }; + } + resolve(); + }); + }); +} + +async function saveState() { + return new Promise((resolve) => { + chrome.storage.local.set({ timerState }, () => resolve()); + }); +} + +// ----- Loop do timer ----- +function stopInterval() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } +} + +async function tick() { + if (!timerState.isRunning || !timerState.endTime) return; + + const now = Date.now(); + const remainingMs = timerState.endTime - now; + + // Calcular segundos restantes com Math.ceil para evitar "quebra" visual + timerState.timeRemaining = Math.max(0, Math.ceil(remainingMs / 1000)); + timerState.lastUpdated = now; + + console.log('[BG] tick:', timerState.timeRemaining, 'segundos restantes'); + + if (remainingMs <= 0) { + // Registrar sessão concluída antes de resetar (apenas se for modo de foco) + await recordSessionCompletion(); + timerState.isRunning = false; + timerState.timeRemaining = MODES[timerState.mode] || MODES.focus; + timerState.endTime = null; + stopInterval(); + // Tocar som de conclusão + await playTimerFinishedSound(); + } + + await saveState(); +} + +function ensureInterval() { + if (timerState.isRunning && !timerInterval) { + timerInterval = setInterval(tick, 1000); + } + if (!timerState.isRunning) { + stopInterval(); + } +} + +// ----- Ações ----- +async function startTimer() { + const now = Date.now(); + timerState.isRunning = true; + timerState.lastUpdated = now; + // Calcular timestamp absoluto de quando o timer deve acabar + timerState.endTime = now + (timerState.timeRemaining * 1000); + await saveState(); + ensureInterval(); +} + +async function pauseTimer() { + // Atualizar timeRemaining antes de pausar (baseado em endTime) + if (timerState.endTime) { + const now = Date.now(); + const remainingMs = timerState.endTime - now; + timerState.timeRemaining = Math.max(0, Math.ceil(remainingMs / 1000)); + } + timerState.isRunning = false; + timerState.endTime = null; + await saveState(); + ensureInterval(); +} + +async function resetTimer(mode) { + const newMode = mode || timerState.mode; + timerState.mode = newMode; + timerState.timeRemaining = MODES[newMode] || MODES.focus; + timerState.isRunning = false; + timerState.endTime = null; + timerState.lastUpdated = Date.now(); + await saveState(); + ensureInterval(); +} + +async function setMode(mode) { + await resetTimer(mode); +} + +// ----- Audio Functions ----- +async function ensureOffscreenDocument() { + console.log('[BG] 🔍 Verificando offscreen document...'); + + try { + // Verificar se já existe + const existingClients = await chrome.runtime.getContexts({ + contextTypes: ['OFFSCREEN_DOCUMENT'], + documentUrls: [chrome.runtime.getURL('offscreen.html')] + }); + + if (existingClients.length > 0) { + console.log('[BG] ✅ Offscreen document já existe'); + return true; + } + + console.log('[BG] 📄 Criando offscreen document...'); + + await chrome.offscreen.createDocument({ + url: 'offscreen.html', + reasons: ['AUDIO_PLAYBACK'], + justification: 'Reprodução de sons de notificação do timer' + }); + + console.log('[BG] ✅ Offscreen document criado com sucesso'); + return true; + + } catch (error) { + // Erro esperado se já existe + if (error.message?.includes('offscreen document already exists') || + error.message?.includes('Only a single offscreen')) { + console.log('[BG] ✅ Offscreen já existe (erro esperado)'); + return true; + } + + console.error('[BG] ❌ Erro ao criar offscreen:', error); + return false; + } +} + +async function playTimerFinishedSound() { + console.log('[BG] 🔔 Timer finalizado, iniciando som...'); + + try { + // Garantir offscreen existe + const offscreenOk = await ensureOffscreenDocument(); + if (!offscreenOk) { + console.error('[BG] ❌ Falha ao criar offscreen'); + return; + } + + // Obter configurações + const data = await chrome.storage.local.get(['soundType', 'volume']); + const soundType = data.soundType || 'sparkle'; + const volume = data.volume !== undefined ? data.volume : 70; + + console.log(`[BG] 📤 Enviando mensagem: soundType=${soundType}, volume=${volume}`); + + // Enviar mensagem para offscreen + const response = await chrome.runtime.sendMessage({ + action: 'playTimerFinishedSound', + soundType, + volume + }); + + if (response && response.success) { + console.log('[BG] ✅ Som tocado com sucesso'); + } else { + console.warn('[BG] ⚠️ Resposta indica falha:', response); + } + + } catch (e) { + console.error('[BG] ❌ Erro ao tocar som timer:', e); + } +} + +async function playTestSound(soundType, volume) { + console.log(`[BG] 🧪 Teste de som: ${soundType}, volume: ${volume}`); + + try { + // Garantir offscreen existe + const offscreenOk = await ensureOffscreenDocument(); + if (!offscreenOk) { + console.error('[BG] ❌ Falha ao criar offscreen'); + return; + } + + console.log('[BG] 📤 Enviando mensagem de teste...'); + + // Enviar mensagem para offscreen + const response = await chrome.runtime.sendMessage({ + action: 'testSound', + soundType: soundType || 'sparkle', + volume: volume !== undefined ? volume : 70 + }); + + if (response && response.success) { + console.log('[BG] ✅ Teste de som concluído com sucesso'); + } else { + console.warn('[BG] ⚠️ Teste falhou:', response); + } + + } catch (e) { + console.error('[BG] ❌ Erro no teste de som:', e); + } +} + +// ----- Spotify OAuth & API ----- +const SPOTIFY_CONFIG = { + clientId: 'YOUR_SPOTIFY_CLIENT_ID_HERE', + redirectUrl: chrome.identity.getRedirectURL(''), + authEndpoint: 'https://accounts.spotify.com/authorize', + apiBase: 'https://api.spotify.com/v1' +}; + +async function getSpotifyToken() { + return new Promise((resolve) => { + chrome.storage.local.get('spotifyToken', (data) => { + resolve(data.spotifyToken || null); + }); + }); +} + +async function saveSpotifyToken(token, expiresIn = 3600) { + const expiresAt = Date.now() + (expiresIn * 1000); + return new Promise((resolve) => { + chrome.storage.local.set({ spotifyToken: token, spotifyTokenExpires: expiresAt }, resolve); + }); +} + +async function authenticateSpotify() { + return new Promise((resolve, reject) => { + const scopes = ['user-read-playback-state', 'user-modify-playback-state', 'user-read-currently-playing']; + const authUrl = new URL(SPOTIFY_CONFIG.authEndpoint); + authUrl.searchParams.append('client_id', SPOTIFY_CONFIG.clientId); + authUrl.searchParams.append('response_type', 'token'); + authUrl.searchParams.append('redirect_uri', SPOTIFY_CONFIG.redirectUrl); + authUrl.searchParams.append('scope', scopes.join(' ')); + authUrl.searchParams.append('show_dialog', 'true'); + + chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true }, (redirectUrl) => { + if (chrome.runtime.lastError) { + console.error('[Spotify] Auth error:', chrome.runtime.lastError); + reject(chrome.runtime.lastError); + return; + } + + if (!redirectUrl) { + reject(new Error('No redirect URL received')); + return; + } + + const url = new URL(redirectUrl); + const token = url.hash.match(/access_token=([^&]+)/)?.[1]; + const expiresIn = url.hash.match(/expires_in=([^&]+)/)?.[1]; + + if (!token) { + reject(new Error('No access token in response')); + return; + } + + saveSpotifyToken(token, parseInt(expiresIn) || 3600).then(() => { + console.log('[Spotify] Token autenticado e salvo'); + resolve({ success: true, token }); + }); + }); + }); +} + +async function spotifyApiCall(endpoint, options = {}) { + const token = await getSpotifyToken(); + if (!token) throw new Error('Spotify not authenticated'); + + const response = await fetch(`${SPOTIFY_CONFIG.apiBase}${endpoint}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers + }, + method: options.method || 'GET', + body: options.body ? JSON.stringify(options.body) : undefined + }); + + if (!response.ok) { + if (response.status === 401) { + chrome.storage.local.remove('spotifyToken'); + throw new Error('Token expirado'); + } + throw new Error(`Spotify API error: ${response.status}`); + } + + return response.json(); +} + +async function getCurrentTrack() { + try { + const data = await spotifyApiCall('/me/player/currently-playing'); + if (!data.item) return null; + return { + name: data.item.name, + artist: data.item.artists?.[0]?.name || 'Unknown', + isPlaying: data.is_playing, + progress: data.progress_ms, + duration: data.item.duration_ms + }; + } catch (e) { + console.warn('[Spotify] Erro ao obter música atual:', e); + return null; + } +} + +async function playPause() { + try { + const current = await spotifyApiCall('/me/player'); + if (current.is_playing) { + await spotifyApiCall('/me/player/pause', { method: 'PUT' }); + } else { + await spotifyApiCall('/me/player/play', { method: 'PUT' }); + } + return { success: true }; + } catch (e) { + console.error('[Spotify] Play/Pause error:', e); + return { success: false, error: e.message }; + } +} + +async function nextTrack() { + try { + await spotifyApiCall('/me/player/next', { method: 'POST' }); + return { success: true }; + } catch (e) { + console.error('[Spotify] Next error:', e); + return { success: false, error: e.message }; + } +} + +async function prevTrack() { + try { + await spotifyApiCall('/me/player/previous', { method: 'POST' }); + return { success: true }; + } catch (e) { + console.error('[Spotify] Previous error:', e); + return { success: false, error: e.message }; + } +} + +// ----- Inicialização ----- +(async function init() { + await loadState(); + ensureInterval(); + console.log('[BG] Estado inicial:', timerState); +})(); + +// ----- Listener de mensagens ----- +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + console.log('[BG] Recebi comando:', message.action); + + if (message.action === 'ping') { + sendResponse({ success: true, pong: true }); + return true; + } + + if (message.action === 'getTimerState') { + console.log('[BG] Respondendo getTimerState com:', timerState); + sendResponse({ success: true, data: timerState }); + return true; + } + + if (message.action === 'startTimer') { + startTimer().then(() => { + console.log('[BG] Timer iniciado'); + }); + sendResponse({ success: true }); + return true; + } + + if (message.action === 'pauseTimer') { + pauseTimer().then(() => { + console.log('[BG] Timer pausado'); + }); + sendResponse({ success: true }); + return true; + } + + if (message.action === 'resetTimer') { + resetTimer(message.mode).then(() => { + console.log('[BG] Timer resetado'); + }); + sendResponse({ success: true }); + return true; + } + + if (message.action === 'setMode') { + setMode(message.mode).then(() => { + console.log('[BG] Modo alterado'); + }); + sendResponse({ success: true }); + return true; + } + + if (message.action === 'testSound') { + playTestSound(message.soundType, message.volume).then(() => { + console.log('[BG] Som de teste tocado'); + }); + sendResponse({ success: true }); + return true; + } + + if (message.action === 'spotifyAuth') { + authenticateSpotify().then(result => { + sendResponse({ success: true, message: 'Spotify autenticado com sucesso!' }); + }).catch(err => { + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + if (message.action === 'getCurrentTrack') { + getCurrentTrack().then(track => { + sendResponse({ success: true, track }); + }).catch(err => { + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + if (message.action === 'playPause') { + playPause().then(result => { + sendResponse({ success: true }); + }).catch(err => { + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + if (message.action === 'nextTrack') { + nextTrack().then(result => { + sendResponse({ success: true }); + }).catch(err => { + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + if (message.action === 'prevTrack') { + prevTrack().then(result => { + sendResponse({ success: true }); + }).catch(err => { + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + console.warn('[BG] Ação desconhecida:', message.action); + sendResponse({ success: false, error: 'unknown_action' }); + return true; +}); diff --git a/study-ai-v1.0.0-stable/chart.js b/study-ai-v1.0.0-stable/chart.js new file mode 100644 index 0000000..77ef218 --- /dev/null +++ b/study-ai-v1.0.0-stable/chart.js @@ -0,0 +1,2 @@ +/*! Chart.js v4.4.0 | MIT License | https://www.chartjs.org/ */ +(function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?factory(exports):typeof define==='function'&&define.amd?define(['exports'],factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,factory(global.Chart={}))}(this,(function(exports){'use strict';const CANVAS_KEY='canvas';const CSS_KEY_REGEX=/^([\w-]+)=(.+)$/;const CSS_VALUE_REGEX=/^(.*?):\s*(.*)$/;const DEFAULT_BOXMODEL_MARGIN=0;const DEFAULT_PADDING=0;const LINE_HEIGHT_REGEX=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/;const FONT_WEIGHT_REGEX=/^(normal|bold|(\d+))$/;const FONT_STYLE_REGEX=/^(normal|italic|oblique)$/;const FONT_VARIANT_REGEX=/^(normal|small-caps)$/;const FONT_FAMILY_REGEX=/^(([-\w]+\s*,\s*)*[-\w]+)(\s*\/\s*(normal|bold|(\d+)?))?(\s*,\s*)?(italic|oblique)?(\s+small-caps)?$/;const FONT_REGEX=/^\s*(italic|oblique|normal)?\s*(small-caps)?\s*(bold|(\d+))?\s*(.+)\s*$/;const OFFSET_PROPS=['x','y'];const BORDER_PROPS=['top','right','bottom','left'];const CORNERS=['topLeft','topRight','bottomLeft','bottomRight'];const ALIAS_PROPS=['fill','stroke','borderDash','borderDashOffset','borderJoinStyle','borderLineCap','borderLineWidth','borderMiterLimit','borderSkipped','borderWidth','drawBorder'];const DEFAULT_CANVAS_HEIGHT=150;const DEFAULT_CANVAS_WIDTH=300;const LINE_STEPS=[],LINE_STEPS_COUNT=6;for(let i=1;icloneDeep(item))}if(isPlainObject(source)){const target={};for(const key in source){target[key]=cloneDeep(source[key])}return target}return source}class Chart$3{static helpers={noop:noop,uid:0,toTRBL:toTRBL,toTRBLCorners:toTRBLCorners,mergeConfig:mergeConfig,deepMerge:deepMerge,cloneDeep:cloneDeep,toFloat:toFloat,parseInt:parseInt,isArray:isArray,isObject:isObject,isNumber:isNumber,isString:isString,isFunction:isFunction,isBoolean:isBoolean,isFinite:isFinite,valueOrDefault:valueOrDefault,isPlainObject:isPlainObject,isPatternOrGradient:isPatternOrGradient,isEvent:isEvent,noop:noop};static version='4.4.0';}window.Chart=Chart$3;Chart$3.Chart=Chart$2;exports.Chart=Chart$3;Object.defineProperty(exports,'__esModule',{value:true})})));if(typeof window!=='undefined'){window.Chart=window.Chart||Chart}(function(){const canvas=document.createElement('canvas');const ctx=canvas.getContext('2d');window.Chart.canvasHelper={drawRect:function(x,y,w,h,fill){ctx.fillRect(x,y,w,h)},drawLine:function(x0,y0,x1,y1){ctx.beginPath();ctx.moveTo(x0,y0);ctx.lineTo(x1,y1);ctx.stroke()}};const ChartType={bar:function(ctx,data,options){const{labels,datasets}=data;const{scales,plugins}=options||{};let maxValue=0;datasets.forEach(d=>{d.data.forEach(v=>{maxValue=Math.max(maxValue,v)})});const width=ctx.canvas.width;const height=ctx.canvas.height;const barWidth=width/(labels.length*1.5);const barGap=barWidth*0.25;ctx.fillStyle='#ccc';ctx.fillRect(0,0,width,height);ctx.fillStyle='#63b0ff';let x=20;datasets[0].data.forEach((value,i)=>{const barHeight=(value/maxValue)*(height-40);ctx.fillRect(x,height-20-barHeight,barWidth,barHeight);x+=barWidth+barGap})},doughnut:function(ctx,data,options){const{labels,datasets}=data;const{scales,plugins}=options||{};const values=datasets[0].data;const total=values.reduce((a,b)=>a+b,0);const colors=['#63b0ff','#8bd0ff','#2a7ef5','#f59e0b','#10b981','#ef4444','#a78bfa'];const centerX=ctx.canvas.width/2;const centerY=ctx.canvas.height/2;const radius=Math.min(centerX,centerY)*0.7;let currentAngle=0;values.forEach((value,i)=>{const sliceAngle=(value/total)*TWO_PI;ctx.fillStyle=colors[i%colors.length];ctx.beginPath();ctx.arc(centerX,centerY,radius,currentAngle,currentAngle+sliceAngle);ctx.lineTo(centerX,centerY);ctx.fill();currentAngle+=sliceAngle})}};window.ChartType=ChartType})();function Chart(ctx,config){const type=config.type||'bar';const Chart$4=window.Chart||{};this.ctx=ctx;this.data=config.data||{};this.options=config.options||{};this.type=type;this.draw=function(){const drawFn=window.ChartType&&window.ChartType[type];if(drawFn){drawFn(this.ctx,this.data,this.options)}};this.update=function(){this.draw()};this.destroy=function(){}}window.Chart=Chart; diff --git a/study-ai-v1.0.0-stable/icons/icon128.png b/study-ai-v1.0.0-stable/icons/icon128.png new file mode 100644 index 0000000..201b63d Binary files /dev/null and b/study-ai-v1.0.0-stable/icons/icon128.png differ diff --git a/study-ai-v1.0.0-stable/manifest.json b/study-ai-v1.0.0-stable/manifest.json new file mode 100644 index 0000000..71f35ae --- /dev/null +++ b/study-ai-v1.0.0-stable/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "Study AI", + "version": "1.0", + "default_locale": "pt_BR", + "description": "Timer Pomodoro inteligente com checklist e persistência.", + "icons": { + "16": "icons/icon128.png", + "48": "icons/icon128.png", + "128": "icons/icon128.png" + }, + "action": { + "default_icon": "icons/icon128.png", + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js" + }, + "permissions": ["storage", "alarms", "offscreen", "identity"], + "host_permissions": [ + "https://api.spotify.com/*", + "https://actions.google.com/*" + ], + "web_accessible_resources": [ + { + "resources": [ + "offscreen.html", + "assets/*" + ], + "matches": [""] + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.spotify.com https://accounts.spotify.com" + }, + "oauth2": { + "client_id": "YOUR_SPOTIFY_CLIENT_ID_HERE", + "scopes": ["user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing"] + } +} \ No newline at end of file diff --git a/study-ai-v1.0.0-stable/offscreen.html b/study-ai-v1.0.0-stable/offscreen.html new file mode 100644 index 0000000..e5d5358 --- /dev/null +++ b/study-ai-v1.0.0-stable/offscreen.html @@ -0,0 +1,12 @@ + + + + + + Offscreen Audio Player + + + + + + diff --git a/study-ai-v1.0.0-stable/offscreen.js b/study-ai-v1.0.0-stable/offscreen.js new file mode 100644 index 0000000..2180d99 --- /dev/null +++ b/study-ai-v1.0.0-stable/offscreen.js @@ -0,0 +1,245 @@ +// Offscreen Document - Reprodução de Áudio (Manifest V3) +// Sistema de Alerta Sintético com Web Audio API + +console.log('[Offscreen] ✅ Documento carregado - Sistema de Alerta Sintético'); + +// Estado do AudioContext (compartilhado) +let audioContextGlobal = null; + +// Obter ou criar AudioContext +function getAudioContext() { + if (!audioContextGlobal) { + audioContextGlobal = new (window.AudioContext || window.webkitAudioContext)(); + console.log('[Offscreen] 🆕 AudioContext criado'); + } + + // Resumir se suspenso + if (audioContextGlobal.state === 'suspended') { + console.log('[Offscreen] ⏸️ AudioContext suspenso, tentando resume...'); + audioContextGlobal.resume(); + } + + return audioContextGlobal; +} + +// 🎵 SONS SINTÉTICOS PREMIUM (3 segundos cada) + +// ✨ Sparkle: Sequência rápida de 3 tons agudos (tri-plim) +async function playSynthSparkle(ctx, volume) { + console.log('[Offscreen] ✨ Tocando Sparkle sintético'); + + const frequencies = [1047, 1319, 1568]; // C6, E6, G6 + const startTime = ctx.currentTime; + + frequencies.forEach((freq, index) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.frequency.value = freq; + oscillator.type = 'sine'; + + const time = startTime + (index * 0.15); + + // Envelope suave + gainNode.gain.setValueAtTime(0, time); + gainNode.gain.linearRampToValueAtTime(volume * 0.4, time + 0.02); // Attack suave + gainNode.gain.exponentialRampToValueAtTime(0.01, time + 0.4); // Decay suave + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(time); + oscillator.stop(time + 0.4); + }); +} + +// 🎹 Piano: Acorde C-Major (Dó Maior) suave por 2 segundos +async function playSynthPiano(ctx, volume) { + console.log('[Offscreen] 🎹 Tocando Piano sintético'); + + const frequencies = [261.63, 329.63, 392.00]; // C4, E4, G4 (Dó Maior) + const startTime = ctx.currentTime; + const duration = 2; + + frequencies.forEach((freq) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.frequency.value = freq; + oscillator.type = 'triangle'; // Som mais suave que square + + // Envelope ADSR suave + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(volume * 0.3, startTime + 0.05); // Attack + gainNode.gain.linearRampToValueAtTime(volume * 0.25, startTime + 0.2); // Decay + gainNode.gain.setValueAtTime(volume * 0.25, startTime + duration - 0.5); // Sustain + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Release + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + duration); + }); +} + +// 🔔 Bell: Tom de sino clássico com decay longo +async function playSynthBell(ctx, volume) { + console.log('[Offscreen] 🔔 Tocando Bell sintético'); + + const fundamentalFreq = 523.25; // C5 + const harmonics = [1, 2, 3, 4.2, 5.4]; // Harmônicos de sino + const startTime = ctx.currentTime; + const duration = 3; + + harmonics.forEach((harmonic, index) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.frequency.value = fundamentalFreq * harmonic; + oscillator.type = 'sine'; + + const amplitude = volume * 0.2 * (1 / (index + 1)); // Harmônicos mais fracos + + // Envelope com decay longo (característico de sino) + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(amplitude, startTime + 0.01); // Attack rápido + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); // Decay longo + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + duration); + }); +} + +// 🎵 Chime: Dois tons alternados (alto/baixo) suaves +async function playSynthChime(ctx, volume) { + console.log('[Offscreen] 🎵 Tocando Chime sintético'); + + const frequencies = [880, 659.25]; // A5, E5 + const startTime = ctx.currentTime; + + frequencies.forEach((freq, index) => { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.frequency.value = freq; + oscillator.type = 'sine'; + + const time = startTime + (index * 0.6); + + // Envelope suave + gainNode.gain.setValueAtTime(0, time); + gainNode.gain.linearRampToValueAtTime(volume * 0.35, time + 0.03); + gainNode.gain.exponentialRampToValueAtTime(0.01, time + 1.2); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(time); + oscillator.stop(time + 1.2); + }); +} + +// 🎼 Função principal: Tocar som sintético por tipo +async function playSyntheticSound(soundType, volumePercent) { + console.log(`[Offscreen] 🎼 Iniciando som sintético: ${soundType} (${volumePercent}%)`); + + const ctx = getAudioContext(); + const volume = Math.min(1, Math.max(0, volumePercent / 100)); + + return new Promise((resolve) => { + // Selecionar função baseada no tipo + switch(soundType) { + case 'sparkle': + playSynthSparkle(ctx, volume); + break; + case 'piano': + playSynthPiano(ctx, volume); + break; + case 'bell': + playSynthBell(ctx, volume); + break; + case 'chime': + playSynthChime(ctx, volume); + break; + default: + console.warn(`[Offscreen] ⚠️ Tipo desconhecido: ${soundType}, usando sparkle`); + playSynthSparkle(ctx, volume); + } + + // Resolver após 3 segundos + setTimeout(() => { + console.log('[Offscreen] ✅ Som sintético concluído'); + resolve(); + }, 3000); + }); +} + +// Message Listener - PONTO DE ENTRADA +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Filtro: Ignorar silenciosamente ações que não são de áudio + if (!message || !message.action) { + return false; // Não processar + } + + // Ações filtradas (não geram log/erro - são tratadas por outros componentes) + const ignoredActions = ['getCurrentTrack', 'getTimerState', 'pollTimer']; + if (ignoredActions.includes(message.action)) { + return false; // Ignorar silenciosamente + } + + console.log('[Offscreen] 📨 Mensagem recebida:', JSON.stringify(message)); + + // Processar ações de áudio + if (message.action === 'playTimerFinishedSound' || message.action === 'testSound') { + const soundType = message.soundType || 'sparkle'; + const volume = message.volume !== undefined ? message.volume : 70; + + console.log(`[Offscreen] 🔊 Ação: ${message.action}, Som: ${soundType}, Volume: ${volume}%`); + + // Tocar som sintético + playSyntheticSound(soundType, volume) + .then(() => { + console.log('[Offscreen] ✅ Som tocado com sucesso'); + sendResponse({ success: true }); + }) + .catch((error) => { + console.error('[Offscreen] ❌ Erro ao tocar som:', error); + sendResponse({ success: false, error: error.message }); + }); + + return true; // Manter canal aberto para sendResponse assíncrono + } + + // Ação de desbloqueio (autorizar áudio) + if (message.action === 'unlockAudio') { + console.log('[Offscreen] 🔓 Desbloqueio de áudio solicitado'); + try { + const ctx = getAudioContext(); + // Criar silêncio de 1ms para desbloquear + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + gainNode.gain.value = 0.001; + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + oscillator.start(); + oscillator.stop(ctx.currentTime + 0.001); + + console.log('[Offscreen] ✅ Áudio desbloqueado'); + sendResponse({ success: true }); + } catch (error) { + console.error('[Offscreen] ❌ Erro ao desbloquear:', error); + sendResponse({ success: false, error: error.message }); + } + return true; + } + + console.warn('[Offscreen] ⚠️ Ação desconhecida:', message.action); + sendResponse({ success: false, error: 'Ação desconhecida' }); + return true; +}); + +console.log('[Offscreen] 🚀 Sistema de alerta sintético inicializado e pronto'); diff --git a/study-ai-v1.0.0-stable/popup.html b/study-ai-v1.0.0-stable/popup.html new file mode 100644 index 0000000..7eb1561 --- /dev/null +++ b/study-ai-v1.0.0-stable/popup.html @@ -0,0 +1,392 @@ + + + + + + Study AI + + + + + + +
+
+
Study AI Logo

Study AI

+
+ + + + +
+
+
25:00
+
+ + +
+ + + + +
+
+ + +
+
+ + +
+
+ +
Tema atual: Programação
+
+
+ + +
+
+
+

📊 Produtividade

+
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
Não conectado
+
+
+
+
+ + +
+
+
+
+
+ + + + + diff --git a/study-ai-v1.0.0-stable/popup.js b/study-ai-v1.0.0-stable/popup.js new file mode 100644 index 0000000..ecca783 --- /dev/null +++ b/study-ai-v1.0.0-stable/popup.js @@ -0,0 +1,1416 @@ +/** + * Study AI - Popup Interface + * Manifest V3 compatible with robust error handling + */ +(function() { + // ----- Cores Fixas por Matéria ----- + const MATERIA_COLORS = { + 'Python': '#3776ab', + 'JavaScript': '#f7df1e', + 'Cloud': '#ff9900', + 'DevOps': '#00add8', + 'Infra': '#2496ed', + 'Idiomas': '#8b5cf6', + 'Músicas': '#ec4899', + 'Musicas': '#ec4899', + 'Musica': '#ec4899', + 'Música': '#ec4899', + 'Sem Categoria': '#a0a8c1', + 'Default': '#8b5cf6' + }; + + function getSubjectColor(subject) { + return MATERIA_COLORS[subject] || MATERIA_COLORS['Default']; + } + + // ----- Traduções Completas (PT-BR e EN) ----- + const TRANSLATIONS = { + 'pt-BR': { + // Abas + focusTab: 'Foco', + statsTab: 'Estatísticas', + settingsTab: 'Configurações', + + // Botões principais + startBtn: 'Iniciar', + pauseBtn: 'Pausar', + resetBtn: 'Resetar', + + // Labels da aba Foco + timerMode: 'Modo do Timer', + studyTheme: 'Tema de Estudo', + focus25m: 'Foco (25m)', + shortBreak5m: 'Pausa Curta (5m)', + longBreak15m: 'Pausa Longa (15m)', + currentTheme: 'Tema atual:', + customThemePlaceholder: 'Digite seu tema personalizado...', + pressEnter: 'Pressione Enter para salvar', + customize: 'Personalizar…', + + // Temas de Estudo (categorias) + 'Programação': 'Programação', + 'Concursos': 'Concursos', + 'Idiomas': 'Idiomas', + 'Matemática': 'Matemática', + 'Leitura': 'Leitura', + + // Aba Estatísticas + dashboard: '📊 Dashboard', + productivityTitle: '📊 Produtividade', + week: 'Semana', + month: 'Mês', + year: 'Ano', + importJson: 'Importar JSON', + exportJson: 'Exportar JSON', + + // Aba Configurações + soundType: 'Som de Conclusão', + volume: 'Volume:', + language: 'Idioma', + theme: 'Tema:', + darkMode: 'Escuro', + lightMode: 'Claro', + spotifyConnect: 'Conectar Spotify', + spotifyDisconnected: 'Não conectado', + spotifyConnected: '✅ Conectado' + }, + 'en': { + // Tabs + focusTab: 'Focus', + statsTab: 'Stats', + settingsTab: 'Settings', + + // Main buttons + startBtn: 'Start', + pauseBtn: 'Pause', + resetBtn: 'Reset', + + // Focus tab labels + timerMode: 'Timer Mode', + studyTheme: 'Study Theme', + focus25m: 'Focus (25m)', + shortBreak5m: 'Short Break (5m)', + longBreak15m: 'Long Break (15m)', + currentTheme: 'Current theme:', + customThemePlaceholder: 'Enter your custom theme...', + pressEnter: 'Press Enter to save', + customize: 'Customize…', + + // Study Themes (categories) + 'Programação': 'Programming', + 'Concursos': 'Competitive Exams', + 'Idiomas': 'Languages', + 'Matemática': 'Mathematics', + 'Leitura': 'Reading', + + // Stats tab + dashboard: '📊 Dashboard', + productivityTitle: '📊 Productivity', + week: 'Week', + month: 'Month', + year: 'Year', + importJson: 'Import JSON', + exportJson: 'Export JSON', + + // Settings tab + soundType: 'Completion Sound', + volume: 'Volume:', + language: 'Language', + theme: 'Theme:', + darkMode: 'Dark', + lightMode: 'Light', + spotifyConnect: 'Connect Spotify', + spotifyDisconnected: 'Not connected', + spotifyConnected: '✅ Connected' + } + }; + + // ----- Estado ----- + const State = { + currentMode: 'focus', + isRunning: false, + activeTab: 'focus', + theme: 'dark', + language: 'pt-BR', + soundType: 'sparkle', + volume: 70, + categoriesDefault: ['Programação', 'Concursos', 'Idiomas', 'Matemática', 'Leitura'], + customCategories: [], + currentCategory: 'Programação', + charts: { weekly: null, category: null }, + }; + + // Utilities + const $ = (id) => document.getElementById(id); + const fmtTime = (s) => { + const totalSeconds = Math.ceil(s); // Arredondar para cima para evitar "quebra" visual + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + }; + const getISOWeek = (date) => { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNum = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; + }; + const dayKey = (date) => ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][date.getDay()]; + + // Labels i18n para dias da semana - sempre chamada dinamicamente + function getDayLabelsI18n() { + const keys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + if (chrome?.i18n) { + const translated = keys.map(key => chrome.i18n.getMessage(key)); + if (translated.every(Boolean)) { + console.log('[getDayLabelsI18n] 🌍 Dias traduzidos:', translated); + return translated; + } + } + // Fallback neutro em inglês para evitar prender no PT + const fallback = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + console.log('[getDayLabelsI18n] ⚠️ Usando fallback em EN:', fallback); + return fallback; + } + + // Traduz nomes de matérias se existir chave em messages.json + function translateSubjectName(subject) { + return chrome?.i18n?.getMessage(subject) || subject; + } + + // Traduz nomes de categorias armazenadas para keys i18n + function translateCategoryName(categoryName) { + // Mapeamento de categorias do banco de dados para chaves i18n + const categoryMap = { + 'Sem Categoria': 'noCategory', + 'Idiomas': 'languages', + 'Programação': 'Programação', + 'Concursos': 'Concursos', + 'Matemática': 'Matemática', + 'Leitura': 'Leitura', + 'Outros': 'noCategory' + }; + + const i18nKey = categoryMap[categoryName] || categoryName; + const translated = chrome?.i18n?.getMessage(i18nKey); + + if (translated) { + console.log(`[translateCategoryName] ✅ "${categoryName}" → "${translated}" (key: ${i18nKey})`); + return translated; + } + + // Fallback para tradução existente + const fallback = translateSubjectName(categoryName); + console.log(`[translateCategoryName] ⚠️ "${categoryName}" → "${fallback}" (fallback)`); + return fallback; + } + + // Tab Navigation + function setActiveTab(tab) { + State.activeTab = tab; + chrome.storage.local.set({ activeTab: tab }); + document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === `tab-${tab}`)); + + // Quando aba Stats for ativada, renderizar métricas de estudo + if (tab === 'stats') { + console.log('[setActiveTab] 📈 Aba stats ativada, preparando renderização das métricas'); + // Aguardar aba ficar visível antes de renderizar + setTimeout(() => { + renderStudyMetrics(); + }, 50); // 50ms para aba ficar visível + } + } + + // 🎨 FUNÇÃO PREMIUM: Renderizar Métricas de Estudo com barras empilhadas + async function renderStudyMetrics() { + console.log('[renderStudyMetrics] 📊 Iniciando renderização das métricas de estudo | Idioma:', State.language); + + // Tradução manual robusta do título + const isEnglish = State.language === 'en'; + const titleEl = document.getElementById('productivity-title') || document.querySelector('[data-i18n="productivityTitle"]'); + if (titleEl) { + titleEl.innerText = isEnglish ? '📊 Productivity' : '📊 Produtividade'; + console.log('[renderStudyMetrics] 📋 Título atualizado:', titleEl.innerText); + } + + const container = document.getElementById('chart-container'); + const legendContainer = document.getElementById('legend-container'); + if (!container) { + console.error('[renderStudyMetrics] ❌ Container #chart-container não encontrado'); + return; + } + + try { + // Ler dados do storage + const data = await chrome.storage.local.get(['studySessions']); + const sessions = data.studySessions || []; + + console.log('[renderStudyMetrics] 📚 Sessões encontradas:', sessions.length); + + // Agrupar por dia da semana e matéria + const today = new Date(); + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); // Domingo da semana atual + weekStart.setHours(0, 0, 0, 0); + + // Tradução manual robusta dos dias da semana + const isEnglish = State.language === 'en'; + const labels = isEnglish + ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + : ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + console.log('[renderStudyMetrics] 📅 Labels de dias gerados:', labels, '| Idioma atual:', State.language); + const weekData = Array(7).fill(null).map(() => ({})); // Array de objetos {matéria: minutos} + const subjectsUsed = new Set(); + + // Processar sessões da última semana + sessions.forEach(session => { + const sessionDate = new Date(session.timestamp); + if (sessionDate >= weekStart) { + const dayIndex = sessionDate.getDay(); + const subject = session.category || 'Outros'; + const minutes = Math.floor((session.duration || 0) / 60); + + if (!weekData[dayIndex][subject]) { + weekData[dayIndex][subject] = 0; + } + weekData[dayIndex][subject] += minutes; + subjectsUsed.add(subject); + } + }); + + console.log('[renderStudyMetrics] 📈 Dados processados:', weekData); + + // Calcular altura máxima para escala + const maxMinutes = Math.max( + ...weekData.map(day => Object.values(day).reduce((sum, mins) => sum + mins, 0)), + 60 // Mínimo de 60 minutos (1h) para escala + ); + + // Limpar containers + container.innerHTML = ''; + if (legendContainer) legendContainer.innerHTML = ''; + + // Criar colunas empilhadas para cada dia + weekData.forEach((dayData, dayIndex) => { + const dayTotal = Object.values(dayData).reduce((sum, mins) => sum + mins, 0); + + // Wrapper da coluna + const columnWrapper = document.createElement('div'); + columnWrapper.style.cssText = ` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + position: relative; + `; + + // Container da barra empilhada + const stackedBar = document.createElement('div'); + stackedBar.style.cssText = ` + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + min-height: 5px; + cursor: pointer; + transition: all 200ms ease; + position: relative; + `; + + // Tooltip ao passar o mouse (criamos antes para ligar eventos dos segmentos) + const tooltip = document.createElement('div'); + tooltip.style.cssText = ` + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(26, 26, 46, 0.95); + color: #e9edf5; + padding: 6px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + z-index: 10; + border: 1px solid rgba(138, 43, 226, 0.5); + margin-bottom: 5px; + max-width: 200px; + white-space: pre-line; + `; + + // Se não há dados, mostrar barra vazia + if (dayTotal === 0) { + const emptyBar = document.createElement('div'); + emptyBar.style.cssText = ` + height: 5px; + background: rgba(100, 100, 100, 0.2); + border-radius: 4px; + `; + stackedBar.appendChild(emptyBar); + tooltip.textContent = 'Sem estudos'; + } else { + // Criar segmentos empilhados + const maxHeight = 180; + const totalHeight = (dayTotal / maxMinutes) * maxHeight; + + let currentHeight = 0; + Object.entries(dayData).forEach(([subject, minutes], index) => { + const segmentHeight = (minutes / dayTotal) * totalHeight; + const color = getSubjectColor(subject); + + const segment = document.createElement('div'); + segment.style.cssText = ` + height: ${segmentHeight}px; + background: ${color}; + ${index === 0 ? 'border-radius: 8px 8px 0 0;' : ''} + transition: all 200ms ease; + position: relative; + `; + segment.dataset.subject = subject; + segment.dataset.minutes = minutes; + + // Tooltip por segmento: mostra minutos exatos daquela matéria + segment.addEventListener('mouseenter', () => { + // Tradução manual robusta + const isEnglish = State.language === 'en'; + let translatedSubject = subject; + if (subject === 'Sem Categoria') { + translatedSubject = isEnglish ? 'No category' : 'Sem Categoria'; + } else if (subject === 'Idiomas') { + translatedSubject = isEnglish ? 'Languages' : 'Idiomas'; + } else if (subject === 'Músicas' || subject === 'Musicas' || subject === 'Musica' || subject === 'Música') { + translatedSubject = isEnglish ? 'Music' : subject; + } + tooltip.textContent = `${translatedSubject}: ${minutes}m`; + tooltip.style.opacity = '1'; + stackedBar.style.transform = 'scaleY(1.05)'; + stackedBar.style.filter = 'brightness(1.1)'; + }); + + segment.addEventListener('mouseleave', () => { + // não ocultamos aqui para permitir transição entre segmentos sem flicker + }); + + stackedBar.appendChild(segment); + currentHeight += segmentHeight; + }); + } + + stackedBar.addEventListener('mouseleave', () => { + tooltip.style.opacity = '0'; + stackedBar.style.transform = 'scaleY(1)'; + stackedBar.style.filter = 'brightness(1)'; + }); + + stackedBar.appendChild(tooltip); + + // Label do dia + const label = document.createElement('div'); + label.style.cssText = ` + color: var(--text); + font-size: 10px; + font-weight: 500; + text-align: center; + margin-top: 2px; + `; + label.textContent = labels[dayIndex]; + + // Efeitos de hover + stackedBar.addEventListener('mouseenter', () => { + stackedBar.style.transform = 'scaleY(1.05)'; + tooltip.style.opacity = '1'; + stackedBar.style.filter = 'brightness(1.1)'; + }); + + stackedBar.addEventListener('mouseleave', () => { + stackedBar.style.transform = 'scaleY(1)'; + tooltip.style.opacity = '0'; + stackedBar.style.filter = 'brightness(1)'; + }); + + columnWrapper.appendChild(stackedBar); + columnWrapper.appendChild(label); + container.appendChild(columnWrapper); + }); + + // Criar legenda + if (legendContainer && subjectsUsed.size > 0) { + Array.from(subjectsUsed).forEach(subject => { + const legendItem = document.createElement('div'); + legendItem.style.cssText = ` + display: flex; + align-items: center; + gap: 5px; + `; + + const colorBox = document.createElement('div'); + colorBox.style.cssText = ` + width: 12px; + height: 12px; + background: ${getSubjectColor(subject)}; + border-radius: 2px; + `; + + const subjectLabel = document.createElement('span'); + // Tradução manual robusta de categorias especiais + const isEnglish = State.language === 'en'; + let translated = subject; + if (subject === 'Sem Categoria') { + translated = isEnglish ? 'No category' : 'Sem Categoria'; + } else if (subject === 'Idiomas') { + translated = isEnglish ? 'Languages' : 'Idiomas'; + } else if (subject === 'Músicas' || subject === 'Musicas' || subject === 'Musica' || subject === 'Música') { + translated = isEnglish ? 'Music' : subject; + } + subjectLabel.textContent = translated; + subjectLabel.style.cssText = ` + color: var(--text); + font-weight: 500; + `; + + legendItem.appendChild(colorBox); + legendItem.appendChild(subjectLabel); + legendContainer.appendChild(legendItem); + }); + } + + console.log('[renderStudyMetrics] ✅ Métricas de estudo renderizadas com sucesso!'); + } catch (error) { + console.error('[renderStudyMetrics] ❌ ERRO ao criar métricas:', error); + } + } + + // ----- Sistema de Idiomas (Profissional - 100% Cobertura) ----- + async function updateLanguage(lang) { + State.language = lang; + await chrome.storage.local.set({ language: lang }); + + // 1. Atualizar todos os elementos com data-i18n + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + const translation = TRANSLATIONS[lang]?.[key]; + + if (!translation) return; + + // Para tabs, atualizar apenas o span interno + if (element.classList.contains('tab')) { + const span = element.querySelector('span'); + if (span) span.textContent = translation; + } else { + element.textContent = translation; + } + }); + + // 2. Atualizar placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(element => { + const key = element.getAttribute('data-i18n-placeholder'); + const translation = TRANSLATIONS[lang]?.[key]; + if (translation) element.placeholder = translation; + }); + + // 3. Atualizar opções dos selects (Timer Mode) + const modeSelect = $('modeSelect'); + if (modeSelect) { + modeSelect.querySelectorAll('option').forEach(opt => { + const key = opt.getAttribute('data-i18n'); + if (key && TRANSLATIONS[lang]?.[key]) { + opt.textContent = TRANSLATIONS[lang][key]; + } + }); + } + + // 4. Atualizar opções do filtro de tempo (Stats) + const timeFilter = $('timeFilter'); + if (timeFilter) { + timeFilter.querySelectorAll('option').forEach(opt => { + const key = opt.getAttribute('data-i18n'); + if (key && TRANSLATIONS[lang]?.[key]) { + opt.textContent = TRANSLATIONS[lang][key]; + } + }); + } + + // 5. Atualizar label do tema (Dark/Light) baseado no estado atual + const themeLabel = $('themeLabel'); + if (themeLabel) { + themeLabel.textContent = State.theme === 'dark' + ? TRANSLATIONS[lang].darkMode + : TRANSLATIONS[lang].lightMode; + } + + // 6. IMPORTANTE: Atualizar botão Start/Pause baseado no estado do timer + const btnStartPause = $('btn-start-pause'); + if (btnStartPause) { + btnStartPause.textContent = State.isRunning + ? TRANSLATIONS[lang].pauseBtn + : TRANSLATIONS[lang].startBtn; + } + + // 7. Atualizar nome do tema atual exibido + const currentThemeElement = $('currentTheme'); + if (currentThemeElement && State.currentCategory) { + currentThemeElement.textContent = translateThemeName(State.currentCategory); + } + + // 8. Recarregar categorias para atualizar opções do select + await loadCategories(); + } + + function setupLanguageListener() { + const languageSelect = $('languageSelect'); + if (!languageSelect) { + console.warn('[setupLanguageListener] ⚠️ Seletor de idioma não encontrado'); + return; + } + + // Definir valor atual + languageSelect.value = State.language; + console.log('[setupLanguageListener] ✅ Listener de idioma configurado. Idioma atual:', State.language); + + // Listener de mudança - sempre re-renderizar métricas e gráfico + languageSelect.addEventListener('change', async (e) => { + const newLang = e.target.value; + console.log('[setupLanguageListener] 🌐 MUDANÇA DE IDIOMA DETECTADA:', State.language, '→', newLang); + await updateLanguage(newLang); + // Aguardar um tick para garantir que State.language foi atualizado + await new Promise(r => setTimeout(r, 50)); + // Re-renderizar métricas para atualizar dias, legendas e título instantaneamente + console.log('[setupLanguageListener] 🎨 Renderizando gráfico com novo idioma:', newLang); + renderStudyMetrics(); + }); + } + + // Background Communication (with retry logic) + async function sendMessage(message, maxRetries = 2) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await Promise.race([ + chrome.runtime.sendMessage(message), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)) + ]); + if (response && response.success) return response; + if (attempt < maxRetries) await new Promise(r => setTimeout(r, 100 * attempt)); + } catch (err) { + if (attempt < maxRetries) await new Promise(r => setTimeout(r, 100 * attempt)); + if (attempt >= maxRetries) return null; + } + } + return null; + } + + // Função auxiliar para atualizar texto do botão Start/Pause com tradução + function updateStartPauseButton(isRunning) { + const btnStartPause = $('btn-start-pause'); + if (btnStartPause) { + btnStartPause.textContent = isRunning + ? TRANSLATIONS[State.language].pauseBtn + : TRANSLATIONS[State.language].startBtn; + } + } + // Função auxiliar para traduzir nome de tema + function translateThemeName(themeName) { + return TRANSLATIONS[State.language]?.[themeName] || themeName; + } + async function fullSync() { + const resp = await sendMessage({ action: 'getTimerState' }); + if (resp && resp.data) { + const { timeRemaining, isRunning, mode } = resp.data; + State.isRunning = isRunning; + State.currentMode = mode; + $('timer-display').textContent = fmtTime(timeRemaining); + updateStartPauseButton(isRunning); + $('modeSelect').value = mode; + } + } + + function pollTimer() { + chrome.runtime.sendMessage({ action: 'getTimerState' }, (response) => { + if (!response) return; + if (response && response.data) { + const { timeRemaining, isRunning, mode } = response.data; + State.isRunning = isRunning; State.currentMode = mode; + $('timer-display').textContent = fmtTime(timeRemaining); + updateStartPauseButton(isRunning); + $('modeSelect').value = mode; + } + }); + } + + // ----- Categorias (Tema de estudo) ----- + async function loadCategories() { + const data = await chrome.storage.local.get(['customCategories', 'currentCategory']); + State.customCategories = Array.isArray(data.customCategories) ? data.customCategories : []; + State.currentCategory = data.currentCategory || State.currentCategory; + + const themeSelect = $('themeSelect'); + themeSelect.innerHTML = ''; + const all = [...State.categoriesDefault, ...State.customCategories]; + + // Adicionar opções traduzidas + all.forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = translateThemeName(cat); // Traduzir nome do tema + themeSelect.appendChild(opt); + }); + + // Opção 'Personalizar' traduzida + const customOpt = document.createElement('option'); + customOpt.value = '__custom__'; + customOpt.textContent = TRANSLATIONS[State.language].customize; + themeSelect.appendChild(customOpt); + + if (all.includes(State.currentCategory)) themeSelect.value = State.currentCategory; + else themeSelect.value = State.categoriesDefault[0]; + + // Atualizar texto do tema atual traduzido + $('currentTheme').textContent = translateThemeName(themeSelect.value); + } + + async function saveCategory(cat) { + State.currentCategory = cat; + $('currentTheme').textContent = translateThemeName(cat); // Traduzir nome exibido + await chrome.storage.local.set({ currentCategory: cat }); + } + + async function addCustomCategory(cat) { + if (!cat.trim()) return; + const set = new Set([...(State.customCategories || []), cat.trim()]); + State.customCategories = [...set]; + await chrome.storage.local.set({ customCategories: State.customCategories }); + await saveCategory(cat.trim()); + await loadCategories(); + } + + // ----- Charts (Chart.js) ----- + function destroyCharts() { + // Destruir instâncias antigas para evitar erro 'Canvas is already in use' + if (State.charts.weekly && typeof State.charts.weekly.destroy === 'function') { + try { + State.charts.weekly.destroy(); + console.log('[Charts] Gráfico semanal destruído'); + } catch (e) { + console.error('[Charts] Erro ao destruir gráfico semanal:', e); + } + State.charts.weekly = null; + } + if (State.charts.category && typeof State.charts.category.destroy === 'function') { + try { + State.charts.category.destroy(); + console.log('[Charts] Gráfico de categorias destruído'); + } catch (e) { + console.error('[Charts] Erro ao destruir gráfico de categorias:', e); + } + State.charts.category = null; + } + } + + function ensureCharts() { + const weeklyCanvas = $('weeklyChart'); + const catCanvas = $('categoryChart'); + if (!weeklyCanvas || !catCanvas) { + return false; + } + const weeklyCtx = weeklyCanvas.getContext('2d'); + const catCtx = catCanvas.getContext('2d'); + if (!weeklyCtx || !catCtx) { + return false; + } + + // Destruir gráficos antigos se existirem + destroyCharts(); + + try { + State.charts.weekly = new Chart(weeklyCtx, { + type: 'bar', + data: { + labels: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'], + datasets: [{ label: 'Sessões', data: [0,0,0,0,0,0,0], backgroundColor: '#63b0ff' }] + }, + options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } + }); + + State.charts.category = new Chart(catCtx, { + type: 'doughnut', + data: { labels: ['Sem dados'], datasets: [{ data: [1], backgroundColor: ['#63b0ff','#8bd0ff','#2a7ef5','#f59e0b','#10b981','#ef4444','#a78bfa'] }] }, + options: { responsive: true, maintainAspectRatio: false } + }); + + console.log('[Charts] Gráficos criados com sucesso'); + return true; + } catch (error) { + console.error('[Charts] Erro ao criar gráficos:', error); + return false; + } + } + + // ----- Renderizar Gráfico Premium Flocus Style ----- + function renderChart(data) { + try { + const weeklyCanvas = $('weeklyChart'); + const catCanvas = $('categoryChart'); + if (!weeklyCanvas && !catCanvas) { + console.warn('[renderChart] ⚠️ Canvas não encontrado'); + return; + } + + const sessions = Array.isArray(data) ? data : []; + const isDark = State.theme === 'dark'; + const textColor = isDark ? '#e9edf5' : '#2c3e50'; + + // Verificar idioma atual para tradução manual robusta + const isEnglish = State.language === 'en'; + console.log('[renderChart] 🎨 Renderizando com idioma:', isEnglish ? 'EN' : 'PT-BR'); + + // Função auxiliar para traduzir categorias especiais de forma robusta + function translateCategory(category) { + try { + if (category === 'Sem Categoria') { + return isEnglish ? 'No category' : 'Sem Categoria'; + } + if (category === 'Idiomas') { + return isEnglish ? 'Languages' : 'Idiomas'; + } + // Aceitar todas as variações de Música (com/sem acento, singular/plural) + if (category === 'Músicas' || category === 'Musicas' || category === 'Musica' || category === 'Música') { + return isEnglish ? 'Music' : category; // Mantém a forma original em PT + } + return category; // Mantém categorias de usuário como estão + } catch (error) { + console.error('[translateCategory] Erro ao traduzir categoria:', error); + return category; // Fallback seguro + } + } + + // ===== GRÁFICO SEMANAL: LINE CHART COM DESIGN PREMIUM ===== + if (weeklyCanvas) { + // Destruir instância antiga + if (State.charts.weekly && typeof State.charts.weekly.destroy === 'function') { + State.charts.weekly.destroy(); + } + + // Gerar dados: 7 dias com mock se vazio + const today = new Date(); + const dayLabels = []; + let weeklyData = []; + + // Arrays fixos de dias traduzidos manualmente + const translatedDays = isEnglish + ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + : ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + + if (sessions.length === 0) { + // Mock data: 7 dias com horas aleatórias entre 2h e 9h + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dayOfWeek = d.getDay(); + dayLabels.push(translatedDays[dayOfWeek]); + weeklyData.push(Math.floor(Math.random() * 7) + 2); // 2-9 horas + } + } else { + // Usar dados reais: últimos 7 dias + const dayOrder = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + const agg = { sunday: 0, monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0 }; + sessions.forEach(s => { + const d = new Date(s.date); + const key = dayKey(d); + agg[key] = (agg[key] || 0) + 1; + }); + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dayOfWeek = d.getDay(); + dayLabels.push(translatedDays[dayOfWeek]); + const key = dayOrder[d.getDay()]; + weeklyData.push(agg[key] || 0); + } + } + + // Criar gradiente para preenchimento + const ctx = weeklyCanvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, 0, weeklyCanvas.height); + gradient.addColorStop(0, 'rgba(138, 43, 226, 0.3)'); + gradient.addColorStop(1, 'rgba(138, 43, 226, 0.01)'); + + State.charts.weekly = new Chart(ctx, { + type: 'line', + data: { + labels: dayLabels, + datasets: [{ + label: 'Horas de Estudo', + data: weeklyData, + borderColor: 'rgba(138, 43, 226, 1)', + backgroundColor: gradient, + borderWidth: 2.5, + fill: true, + tension: 0.4, + pointRadius: 4, + pointBackgroundColor: 'rgba(138, 43, 226, 1)', + pointBorderColor: isDark ? '#1a1a2e' : '#ffffff', + pointBorderWidth: 2, + pointHoverRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: isDark ? 'rgba(26, 26, 46, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: textColor, + bodyColor: textColor, + borderColor: 'rgba(138, 43, 226, 0.5)', + borderWidth: 1, + padding: 10, + displayColors: false, + callbacks: { + label: (ctx) => `${ctx.parsed.y}h de estudo` + } + } + }, + scales: { + x: { + display: true, + ticks: { color: textColor, font: { size: 11 } }, + grid: { display: false } + }, + y: { + display: true, + beginAtZero: true, + ticks: { color: textColor, font: { size: 11 } }, + grid: { display: false } + } + } + } + }); + } + + // ===== GRÁFICO DE CATEGORIAS: DOUGHNUT PREMIUM ===== + if (catCanvas) { + if (State.charts.category && typeof State.charts.category.destroy === 'function') { + State.charts.category.destroy(); + } + + const stats = { byTheme: {} }; + sessions.forEach(s => { + const theme = s.theme || 'Sem Categoria'; + stats.byTheme[theme] = (stats.byTheme[theme] || 0) + 1; + }); + + const themes = Object.keys(stats.byTheme); + const themeCounts = themes.map(t => stats.byTheme[t]); + // Traduzir labels das categorias especiais + const labels = themes.length > 0 ? themes.map(t => translateCategory(t)) : ['Sem dados']; + const values = themes.length > 0 ? themeCounts : [1]; + const colors = ['rgba(138, 43, 226, 0.8)', 'rgba(99, 176, 255, 0.8)', 'rgba(42, 126, 245, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(239, 68, 68, 0.8)', 'rgba(167, 139, 250, 0.8)']; + + State.charts.category = new Chart(catCanvas.getContext('2d'), { + type: 'doughnut', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: colors.slice(0, labels.length), + borderColor: isDark ? '#1a1a2e' : '#ffffff', + borderWidth: 2 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: textColor, font: { size: 11 }, padding: 12 }, + position: 'bottom' + }, + tooltip: { + backgroundColor: isDark ? 'rgba(26, 26, 46, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: textColor, + bodyColor: textColor, + borderColor: 'rgba(138, 43, 226, 0.5)', + borderWidth: 1, + callbacks: { + label: (ctx) => { + const value = ctx.parsed || 0; + return `${ctx.label}: ${value} sessão${value !== 1 ? 'ões' : ''}`; + } + } + } + } + } + }); + } + } catch (error) { + console.error('[renderChart] ❌ Erro ao renderizar gráfico:', error); + // Não propagar o erro para não quebrar a interface + } + } + + async function refreshCharts() { + const { stats } = await chrome.storage.local.get(['stats']); + renderChart(Array.isArray(stats) ? stats : []); + } + + // ===== RENDERIZAÇÃO DE GRÁFICO CSS PURO ===== + // ----- Import/Export ----- + async function exportJson() { + const data = await chrome.storage.local.get(['studySessions','weeklyStats','currentWeek','completedSessions','soundType','volume','currentCategory','theme','language','customCategories']); + const exportObj = { + exportDate: new Date().toISOString(), + version: '3.0', + summary: { + totalSessions: data.completedSessions || 0, + currentWeek: data.currentWeek || 'N/A', + currentCategory: data.currentCategory || 'Sem Categoria' + }, + settings: { + soundType: data.soundType || 'sparkle', + volume: data.volume ?? 70, + theme: data.theme || 'dark', + language: data.language || 'pt-BR' + }, + customCategories: data.customCategories || [], + weeklyStats: data.weeklyStats || {}, + sessions: data.studySessions || [] + }; + + const jsonStr = JSON.stringify(exportObj, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const ts = new Date().toISOString().replace(/[:.]/g,'-').slice(0,19); + const a = document.createElement('a'); a.href = url; a.download = `study-ai-export-${ts}.json`; + document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + } + + async function importJson(file) { + try { + const text = await file.text(); + const obj = JSON.parse(text); + const payload = {}; + if (obj.sessions) payload.studySessions = obj.sessions; + if (obj.weeklyStats) payload.weeklyStats = obj.weeklyStats; + if (obj.summary?.currentWeek) payload.currentWeek = obj.summary.currentWeek; + if (obj.settings?.soundType) payload.soundType = obj.settings.soundType; + if (typeof obj.settings?.volume === 'number') payload.volume = obj.settings.volume; + if (obj.settings?.theme) payload.theme = obj.settings.theme; + if (obj.settings?.language) payload.language = obj.settings.language; + if (Array.isArray(obj.customCategories)) payload.customCategories = obj.customCategories; + if (obj.summary?.currentCategory) payload.currentCategory = obj.summary.currentCategory; + await chrome.storage.local.set(payload); + await loadCategories(); + await refreshCharts(); + } catch (err) { + console.warn('Erro ao importar JSON:', err); + } + } + + // ----- Spotify Panel ----- + async function updateSpotifyPanel() { + try { + const response = await sendMessage({ action: 'getCurrentTrack' }); + if (response && response.track) { + const track = response.track; + const isEnglish = State.language === 'en'; + $('spotify-track-name').textContent = track.name || (isEnglish ? 'No music' : 'Nenhuma música'); + $('spotify-artist-name').textContent = track.artist || 'Unknown'; + $('spotify-panel').style.display = 'block'; + $('spotify-play').textContent = track.isPlaying ? '⏸️' : '▶️'; + } else { + $('spotify-panel').style.display = 'none'; + } + } catch (e) { + console.warn('[Popup] Falha ao atualizar painel Spotify:', e); + } + } + + // ----- Carregar Estatísticas de Estudo ----- + async function loadStudyStats() { + try { + const result = await chrome.storage.local.get(['stats']); + let sessions = Array.isArray(result.stats) ? result.stats : []; + console.log('Dados recuperados do storage:', sessions); + + // Se vazio, adicionar dados fictícios para demonstração + if (sessions.length === 0) { + const hasInitialData = await getValueFromStorage('hasInitialDemoData'); + if (!hasInitialData) { + sessions = generateDemoSessions(); + await chrome.storage.local.set({ + stats: sessions, + hasInitialDemoData: true + }); + } + } + + // Renderizar gráfico com os dados + renderChart(sessions); + + console.log('[Popup] Estatísticas carregadas:', sessions.length, 'sessões'); + } catch (error) { + console.error('[Popup] Erro ao carregar estatísticas:', error); + } + } + + // ----- Gerar Dados Fictícios de Demonstração ----- + function generateDemoSessions() { + const demos = []; + const themes = ['Python', 'JavaScript', 'Programação', 'Idiomas', 'Matemática']; + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + // Criar 15 sessões fictícias distribuídas nos últimos 15 dias + for (let i = 0; i < 15; i++) { + const timestamp = now - (i * oneDayMs) + Math.random() * 60000; + demos.push({ + date: new Date(timestamp).toISOString(), + duration: 25, + theme: themes[Math.floor(Math.random() * themes.length)], + mode: 'focus' + }); + } + + return demos; + } + + // ----- Processar Sessões para Gráficos ----- + function processSessions(sessions) { + const stats = { + byDay: { sunday: 0, monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0 }, + byTheme: {}, + total: 0, + totalMinutes: 0 + }; + + sessions.forEach(session => { + const date = new Date(session.date); + const day = dayKey(date); + + stats.byDay[day] += 1; + stats.byTheme[session.theme] = (stats.byTheme[session.theme] || 0) + 1; + stats.total += 1; + stats.totalMinutes += session.duration; + }); + + return stats; + } + + // ----- Atualizar Gráficos com Dados ----- + function updateChartsWithData(stats) { + try { + const weeklyCanvas = $('weeklyChart'); + const catCanvas = $('categoryChart'); + + if (!weeklyCanvas || !catCanvas) return; + + // Atualizar gráfico semanal + const dayOrder = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + const weeklyData = dayOrder.map(day => stats.byDay[day] || 0); + + if (State.charts.weekly) { + State.charts.weekly.data.datasets[0].data = weeklyData; + State.charts.weekly.update(); + } + + // Atualizar gráfico de categorias + const themes = Object.keys(stats.byTheme); + const themeCounts = themes.map(t => stats.byTheme[t]); + + if (State.charts.category) { + State.charts.category.data.labels = themes; + State.charts.category.data.datasets[0].data = themeCounts; + State.charts.category.update(); + } + } catch (error) { + console.error('[Popup] Erro ao atualizar gráficos:', error); + } + } + + // ----- Auxiliar para obter valor do storage ----- + async function getValueFromStorage(key) { + return new Promise((resolve) => { + chrome.storage.local.get([key], (data) => { + resolve(data[key]); + }); + }); + } + + // ----- Função de Teste: Salvar 5 Sessões Fake ----- + // ----- Configurações ----- + async function loadSettings() { + const data = await chrome.storage.local.get(['soundType','volume','language','theme','activeTab']); + State.soundType = data.soundType || State.soundType; + State.volume = data.volume ?? State.volume; + State.language = data.language || State.language; + State.theme = data.theme || State.theme; + State.activeTab = data.activeTab || State.activeTab; + + if ($('soundSelect')) $('soundSelect').value = State.soundType; + $('volumeSlider').value = State.volume; $('volumeValue').textContent = State.volume; + $('languageSelect').value = State.language; + const isDark = State.theme === 'dark'; $('themeToggle').checked = isDark; $('themeLabel').textContent = isDark ? 'Escuro' : 'Claro'; + document.body.classList.toggle('light', !isDark); + document.body.classList.toggle('dark', isDark); + setActiveTab(State.activeTab); + } + + function setupListeners() { + // Tabs + document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => setActiveTab(btn.dataset.tab)); + }); + + // Timer controls - COM SEGURANÇA + if ($('btn-start-pause')) { + $('btn-start-pause').addEventListener('click', async () => { + const action = State.isRunning ? 'pauseTimer' : 'startTimer'; + await sendMessage({ action }); + setTimeout(pollTimer, 100); + }); + } + + if ($('btn-reset')) { + $('btn-reset').addEventListener('click', async () => { + await sendMessage({ action: 'resetTimer', mode: State.currentMode }); + setTimeout(pollTimer, 100); + }); + } + + if ($('modeSelect')) { + $('modeSelect').addEventListener('change', async (e) => { + const mode = e.target.value; State.currentMode = mode; + await sendMessage({ action: 'setMode', mode }); + setTimeout(pollTimer, 100); + }); + } + + // Theme select + if ($('themeSelect')) { + $('themeSelect').addEventListener('change', async (e) => { + if (e.target.value === '__custom__') { + if ($('customThemeRow')) $('customThemeRow').style.display = 'block'; + if ($('customThemeInput')) $('customThemeInput').focus(); + } else { + if ($('customThemeRow')) $('customThemeRow').style.display = 'none'; + await saveCategory(e.target.value); + } + }); + } + + if ($('customThemeInput')) { + $('customThemeInput').addEventListener('keypress', async (ev) => { + if (ev.key === 'Enter') { + await addCustomCategory(ev.target.value.trim()); + if ($('customThemeRow')) $('customThemeRow').style.display = 'none'; + } + }); + } + + // Sound select + if ($('soundSelect')) { + $('soundSelect').addEventListener('change', async (e) => { + State.soundType = e.target.value; + await chrome.storage.local.set({ soundType: State.soundType }); + const volume = parseInt($('volumeSlider')?.value || 70, 10); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume }); + }); + } + + // Volume slider + if ($('volumeSlider')) { + $('volumeSlider').addEventListener('input', (e) => { + if ($('volumeValue')) $('volumeValue').textContent = e.target.value; + }); + + let volumeDebounceTimer = null; + $('volumeSlider').addEventListener('input', async (e) => { + clearTimeout(volumeDebounceTimer); + volumeDebounceTimer = setTimeout(async () => { + const volume = parseInt(e.target.value, 10); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume }); + }, 300); + }); + + $('volumeSlider').addEventListener('change', async (e) => { + State.volume = parseInt(e.target.value, 10); + await chrome.storage.local.set({ volume: State.volume }); + await sendMessage({ action: 'testSound', soundType: State.soundType, volume: State.volume }); + }); + } + + // Language select + if ($('languageSelect')) { + $('languageSelect').addEventListener('change', async (e) => { + const newLang = e.target.value; + State.language = newLang; + await chrome.storage.local.set({ language: State.language }); + + // Atualizar título imediatamente se estiver na aba Stats + const titleEl = document.getElementById('productivity-title'); + if (titleEl) { + const isEnglish = newLang === 'en'; + titleEl.innerText = isEnglish ? '📊 Productivity' : '📊 Produtividade'; + } + + // Re-renderizar gráfico se estiver na aba Stats + if (State.activeTab === 'stats') { + await new Promise(r => setTimeout(r, 50)); + renderStudyMetrics(); + } + }); + } + + // Spotify listeners - COM SEGURANÇA + if ($('btn-spotify-connect')) { + $('btn-spotify-connect').addEventListener('click', async () => { + const response = await sendMessage({ action: 'spotifyAuth' }); + if (response && response.success) { + if ($('spotify-status')) { + $('spotify-status').textContent = '✅ Conectado'; + $('spotify-status').style.color = '#10b981'; + } + if ($('spotify-panel')) $('spotify-panel').style.display = 'block'; + } else { + if ($('spotify-status')) { + $('spotify-status').textContent = '❌ Erro: ' + (response?.error || 'desconhecido'); + $('spotify-status').style.color = '#ef4444'; + } + } + }); + } + + if ($('btn-spotify-play')) { + $('btn-spotify-play').addEventListener('click', async () => { + await sendMessage({ action: 'playPause' }); + updateSpotifyPanel(); + }); + } + + if ($('btn-spotify-next')) { + $('btn-spotify-next').addEventListener('click', async () => { + await sendMessage({ action: 'nextTrack' }); + updateSpotifyPanel(); + }); + } + + if ($('btn-spotify-prev')) { + $('btn-spotify-prev').addEventListener('click', async () => { + await sendMessage({ action: 'prevTrack' }); + updateSpotifyPanel(); + }); + } + + // Theme toggle + if ($('themeToggle')) { + $('themeToggle').addEventListener('change', async (e) => { + const dark = e.target.checked; + State.theme = dark ? 'dark' : 'light'; + if ($('themeLabel')) $('themeLabel').textContent = dark ? 'Escuro' : 'Claro'; + document.body.classList.toggle('light', !dark); + document.body.classList.toggle('dark', dark); + await chrome.storage.local.set({ theme: State.theme }); + }); + } + + // Export/Import - COM SEGURANÇA + if ($('btn-export')) { + $('btn-export').addEventListener('click', exportJson); + } + + if ($('btn-import')) { + $('btn-import').addEventListener('click', () => { + if ($('file-import')) $('file-import').click(); + }); + } + + if ($('file-import')) { + $('file-import').addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (file) await importJson(file); + e.target.value = ''; + }); + } + + // Storage listener + chrome.storage.onChanged.addListener(async (changes, area) => { + if (area !== 'local') return; + if (changes.weeklyStats || changes.studySessions || changes.currentWeek || changes.stats) { + // Métricas foram atualizadas, renderizar novamente se estiver na aba stats + if (State.activeTab === 'stats') { + renderStudyMetrics(); + } + } + if (changes.customCategories || changes.currentCategory) await loadCategories(); + if (changes.timerState) pollTimer(); + }); + } + + // ----- Sistema de Desbloqueio de Áudio ----- + let audioUnlocked = false; + + async function unlockAudio() { + if (audioUnlocked) return; + + try { + // Enviar comando de desbloqueio para offscreen + const response = await sendMessage({ action: 'unlockAudio' }); + if (response && response.success) { + audioUnlocked = true; + } + } catch (e) { + // Silenciar erro - será tentado novamente se necessário + } + } + + // Adicionar listener global para desbloquear no primeiro clique + function setupAudioUnlock() { + const unlockOnInteraction = () => { + unlockAudio(); + // Remover listener após primeira interação + document.removeEventListener('click', unlockOnInteraction); + document.removeEventListener('keydown', unlockOnInteraction); + }; + + document.addEventListener('click', unlockOnInteraction); + document.addEventListener('keydown', unlockOnInteraction); + } + + // ----- Bootstrap ----- + document.addEventListener('DOMContentLoaded', async () => { + console.log('[DOMContentLoaded] 🚀 Inicializando popup'); + + // 🔴 PRIORIDADE 1: Sincronizar estado do Timer do background + await fullSync(); + + // PRIORIDADE 2: Iniciar polling do Timer (mantém sincronizado) + setInterval(pollTimer, 200); + + // Carregar idioma armazenado + const savedLang = await chrome.storage.local.get('language'); + if (savedLang.language) { + State.language = savedLang.language; + } + + // Setup e listeners + setupAudioUnlock(); + setupLanguageListener(); + await loadSettings(); + await loadCategories(); + setupListeners(); + + // Traduções e UI + await updateLanguage(State.language); + await updateSpotifyPanel(); + setInterval(updateSpotifyPanel, 5000); + + console.log('[DOMContentLoaded] ✅ Popup inicializado'); + }); + +})(); \ No newline at end of file