├── .gitignore ├── .env.example ├── img ├── timer.png ├── settings.png ├── breaktime.png └── ai-assistant.png ├── public ├── favicon.ico ├── sounds │ ├── end.mp3 │ ├── start.mp3 │ └── break-start.mp3 ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── css │ ├── variables.css │ ├── styles.css │ ├── base.css │ ├── animations.css │ ├── components │ │ ├── developer.css │ │ ├── history.css │ │ ├── buttons.css │ │ ├── settings.css │ │ ├── timer.css │ │ ├── modals.css │ │ └── ai.css │ ├── 404.css │ └── responsive.css ├── site.webmanifest ├── js │ ├── config.js │ ├── app.js │ ├── ai │ │ ├── service.js │ │ ├── config.js │ │ ├── goalAnalyzer.js │ │ ├── presetManager.js │ │ └── ui.js │ ├── history.js │ ├── audio.js │ ├── notifications.js │ └── timer.js ├── 404.html └── index.html ├── package.json ├── README.md ├── server.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENROUTER_API_KEY=yourapikey -------------------------------------------------------------------------------- /img/timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/img/timer.png -------------------------------------------------------------------------------- /img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/img/settings.png -------------------------------------------------------------------------------- /img/breaktime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/img/breaktime.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /img/ai-assistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/img/ai-assistant.png -------------------------------------------------------------------------------- /public/sounds/end.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/sounds/end.mp3 -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/sounds/start.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/sounds/start.mp3 -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/sounds/break-start.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/sounds/break-start.mp3 -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/PulseTimer/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/css/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #6366f1; 3 | --secondary-color: #ec4899; 4 | --background-start: #1e1e2e; 5 | --background-end: #2d2d44; 6 | --text-color: #ffffff; 7 | --border-color: rgba(255, 255, 255, 0.1); 8 | --input-bg: rgba(255, 255, 255, 0.1); 9 | --card-bg: rgba(255, 255, 255, 0.1); 10 | } -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Import all*/ 2 | @import url('./variables.css'); 3 | @import url('./base.css'); 4 | @import url('./animations.css'); 5 | @import url('./components/timer.css'); 6 | @import url('./components/buttons.css'); 7 | @import url('./components/settings.css'); 8 | @import url('./components/modals.css'); 9 | @import url('./components/history.css'); 10 | @import url('./components/developer.css'); 11 | @import url('./components/ai.css'); 12 | @import url('./responsive.css'); -------------------------------------------------------------------------------- /public/css/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: 'Montserrat', sans-serif; 9 | background: linear-gradient(135deg, var(--background-start), var(--background-end)); 10 | min-height: 100vh; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | color: var(--text-color); 15 | padding: 2rem 0; 16 | } 17 | 18 | .container { 19 | width: 100%; 20 | max-width: 800px; 21 | padding: 2rem; 22 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pulsetimer", 3 | "version": "2.0.0", 4 | "description": "AI-powered work timer with intelligent recommendations and modern design for enhanced productivity", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "dependencies": { 10 | "dotenv": "^17.2.0", 11 | "express": "^4.21.2", 12 | "helmet": "^7.2.0" 13 | }, 14 | "license": "Apache-2.0", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/Efeckc17/PulseTimer" 18 | }, 19 | "author": { 20 | "name": "toxi360", 21 | "url": "https://github.com/Efeckc17" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PulseTimer", 3 | "short_name": "PulseTimer", 4 | "description": "A work timer application with focus on productivity pulses", 5 | "start_url": "/", 6 | "icons": [ 7 | { 8 | "src": "/android-chrome-192x192.png", 9 | "sizes": "192x192", 10 | "type": "image/png" 11 | }, 12 | { 13 | "src": "/android-chrome-512x512.png", 14 | "sizes": "512x512", 15 | "type": "image/png" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone", 21 | "orientation": "any" 22 | } -------------------------------------------------------------------------------- /public/css/animations.css: -------------------------------------------------------------------------------- 1 | @keyframes numberPulse { 2 | 0% { transform: scale(1); } 3 | 50% { transform: scale(1.1); } 4 | 100% { transform: scale(1); } 5 | } 6 | 7 | @keyframes separatorPulse { 8 | 0% { opacity: 0.5; } 9 | 50% { opacity: 0.2; } 10 | 100% { opacity: 0.5; } 11 | } 12 | 13 | @keyframes pulse { 14 | 0% { 15 | transform: scale(1); 16 | opacity: 1; 17 | } 18 | 50% { 19 | transform: scale(1.1); 20 | opacity: 0.8; 21 | } 22 | 100% { 23 | transform: scale(1); 24 | opacity: 1; 25 | } 26 | } 27 | 28 | @keyframes spin { 29 | 0% { transform: rotate(0deg); } 30 | 100% { transform: rotate(360deg); } 31 | } 32 | 33 | .pulse { 34 | animation: pulse 0.5s ease-in-out; 35 | } -------------------------------------------------------------------------------- /public/js/config.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SETTINGS = { 2 | work: { 3 | name: 'Work Time', 4 | hours: 0, 5 | minutes: 25 6 | }, 7 | break: { 8 | name: 'Break Time', 9 | hours: 0, 10 | minutes: 5, 11 | intervalHours: 0, 12 | intervalMinutes: 25 13 | }, 14 | notifications: { 15 | sound: true, 16 | desktop: true 17 | } 18 | }; 19 | 20 | export const STORAGE_KEYS = { 21 | SETTINGS: 'pulseTimer_settings', 22 | HISTORY: 'pulseTimer_history', 23 | NOTIFICATION_PREFERENCE: 'pulseTimer_notifications' 24 | }; 25 | 26 | export const NOTIFICATION_SOUNDS = { 27 | WORK_START: '/sounds/start.mp3', 28 | BREAK_START: '/sounds/break-start.mp3', 29 | SESSION_END: '/sounds/end.mp3' 30 | }; -------------------------------------------------------------------------------- /public/css/components/developer.css: -------------------------------------------------------------------------------- 1 | .developer-info { 2 | margin-top: 2rem; 3 | padding-top: 1rem; 4 | border-top: 1px solid var(--border-color); 5 | text-align: center; 6 | } 7 | 8 | .github-links { 9 | display: flex; 10 | justify-content: center; 11 | gap: 1.5rem; 12 | margin-bottom: 0.5rem; 13 | } 14 | 15 | .developer-info a { 16 | color: var(--text-color); 17 | text-decoration: none; 18 | opacity: 0.8; 19 | transition: opacity 0.3s ease; 20 | font-size: 0.9rem; 21 | } 22 | 23 | .developer-info a:hover { 24 | opacity: 1; 25 | } 26 | 27 | .developer-info i { 28 | margin-right: 0.5rem; 29 | } 30 | 31 | .repo-link { 32 | color: var(--accent-color) !important; 33 | } 34 | 35 | .license-info { 36 | font-size: 0.8rem; 37 | opacity: 0.6; 38 | margin-top: 0.5rem; 39 | } 40 | 41 | .license-info a { 42 | text-decoration: underline; 43 | font-size: 0.8rem; 44 | } 45 | 46 | .support-info { 47 | margin: 1rem 0; 48 | } -------------------------------------------------------------------------------- /public/css/components/history.css: -------------------------------------------------------------------------------- 1 | .session-history { 2 | margin-top: 2rem; 3 | padding-top: 2rem; 4 | border-top: 1px solid var(--border-color); 5 | text-align: left; 6 | } 7 | 8 | .history-list { 9 | max-height: 200px; 10 | overflow-y: auto; 11 | padding: 1rem; 12 | background: rgba(0, 0, 0, 0.2); 13 | border-radius: 10px; 14 | } 15 | 16 | .history-item { 17 | padding: 0.8rem; 18 | border-bottom: 1px solid var(--border-color); 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | } 23 | 24 | .history-item:last-child { 25 | border-bottom: none; 26 | } 27 | 28 | .history-main-info { 29 | display: flex; 30 | flex-direction: column; 31 | gap: 0.25rem; 32 | } 33 | 34 | .history-time { 35 | font-size: 0.8rem; 36 | opacity: 0.7; 37 | color: var(--secondary-color); 38 | } 39 | 40 | .history-action { 41 | font-weight: 500; 42 | } 43 | 44 | .history-details { 45 | display: flex; 46 | flex-direction: column; 47 | gap: 0.25rem; 48 | font-size: 0.8rem; 49 | opacity: 0.8; 50 | } 51 | 52 | .history-note { 53 | font-style: italic; 54 | color: var(--primary-color); 55 | } -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404 - Page Not Found | PulseTimer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
404
28 |
29 |

Oops! Time's Up!

30 |

31 | Looks like you've wandered into a time warp! The page you're looking for has either taken a break 32 | or doesn't exist in this timeline. 33 |

34 | Back to Timer 35 |
36 | 37 | -------------------------------------------------------------------------------- /public/js/app.js: -------------------------------------------------------------------------------- 1 | import { timer } from './timer.js'; 2 | import { notificationManager } from './notifications.js'; 3 | import { historyManager } from './history.js'; 4 | import { aiUI } from './ai/ui.js'; 5 | import { goalAnalyzer } from './ai/goalAnalyzer.js'; 6 | import { presetManager } from './ai/presetManager.js'; 7 | 8 | // UI 9 | const toggleTimerBtn = document.getElementById('toggleTimer'); 10 | const toggleSettingsBtn = document.getElementById('toggleSettings'); 11 | const timerDisplay = document.querySelector('.timer-display'); 12 | const settingsPanel = document.querySelector('.settings-panel'); 13 | const timeSpans = document.querySelectorAll('.time-section span'); 14 | 15 | 16 | toggleTimerBtn.addEventListener('click', () => { 17 | const isVisible = !timerDisplay.classList.contains('masked'); 18 | 19 | if (isVisible) { 20 | 21 | timerDisplay.classList.add('masked'); 22 | timeSpans.forEach(span => { 23 | span.dataset.originalText = span.textContent; 24 | span.textContent = '**'; 25 | }); 26 | } else { 27 | 28 | timerDisplay.classList.remove('masked'); 29 | timeSpans.forEach(span => { 30 | span.textContent = span.dataset.originalText || span.textContent; 31 | }); 32 | } 33 | 34 | const icon = toggleTimerBtn.querySelector('i'); 35 | icon.classList.toggle('fa-eye'); 36 | icon.classList.toggle('fa-eye-slash'); 37 | }); 38 | 39 | 40 | toggleSettingsBtn.addEventListener('click', () => { 41 | settingsPanel.classList.toggle('collapsed'); 42 | toggleSettingsBtn.classList.toggle('collapsed'); 43 | }); 44 | 45 | 46 | const originalUpdateDisplay = timer.updateDisplay; 47 | timer.updateDisplay = function() { 48 | originalUpdateDisplay.call(this); 49 | if (timerDisplay.classList.contains('masked')) { 50 | timeSpans.forEach(span => { 51 | span.textContent = '**'; 52 | }); 53 | } 54 | }; 55 | 56 | window.pulseTimer = { 57 | timer, 58 | notificationManager, 59 | historyManager, 60 | ai: { 61 | ui: aiUI, 62 | goalAnalyzer, 63 | presetManager 64 | } 65 | }; -------------------------------------------------------------------------------- /public/js/ai/service.js: -------------------------------------------------------------------------------- 1 | import { AI_CONFIG, AI_PROMPTS } from './config.js'; 2 | 3 | class AIService { 4 | constructor() { 5 | this.apiEndpoint = AI_CONFIG.API_ENDPOINT; 6 | } 7 | 8 | async makeRequest(prompt, type = 'analyze') { 9 | try { 10 | const response = await fetch(this.apiEndpoint, { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json' 14 | }, 15 | body: JSON.stringify({ 16 | prompt: prompt, 17 | type: type, 18 | maxTokens: AI_CONFIG.MAX_TOKENS, 19 | temperature: AI_CONFIG.TEMPERATURE 20 | }) 21 | }); 22 | 23 | if (!response.ok) { 24 | throw new Error(`API request failed: ${response.status}`); 25 | } 26 | 27 | const data = await response.json(); 28 | 29 | if (data.fallback) { 30 | throw new Error('AI service temporarily unavailable'); 31 | } 32 | 33 | return data.result; 34 | } catch (error) { 35 | console.error('AI Service Error:', error); 36 | throw new Error('Failed to get AI response. Please try again.'); 37 | } 38 | } 39 | 40 | async analyzeGoal(goal) { 41 | try { 42 | const prompt = AI_PROMPTS.goal_analysis.replace('{goal}', goal); 43 | const response = await this.makeRequest(prompt, 'goal_analysis'); 44 | return JSON.parse(response); 45 | } catch (error) { 46 | console.error('Goal analysis failed:', error); 47 | return this.getFallbackSettings(); 48 | } 49 | } 50 | 51 | async recommendPreset(goal) { 52 | try { 53 | const prompt = AI_PROMPTS.preset_recommendation.replace('{goal}', goal); 54 | const response = await this.makeRequest(prompt, 'preset_recommendation'); 55 | return JSON.parse(response); 56 | } catch (error) { 57 | console.error('Preset recommendation failed:', error); 58 | return { preset: 'study', reason: 'Default recommendation for general productivity' }; 59 | } 60 | } 61 | 62 | getFallbackSettings() { 63 | return { 64 | workMinutes: 25, 65 | breakMinutes: 5, 66 | breakInterval: 25, 67 | reason: 'Default Pomodoro technique settings' 68 | }; 69 | } 70 | } 71 | 72 | export const aiService = new AIService(); -------------------------------------------------------------------------------- /public/js/history.js: -------------------------------------------------------------------------------- 1 | import { STORAGE_KEYS } from './config.js'; 2 | 3 | class HistoryManager { 4 | constructor() { 5 | this.history = []; 6 | this.historyList = document.getElementById('historyList'); 7 | this.loadHistory(); 8 | } 9 | 10 | loadHistory() { 11 | const savedHistory = JSON.parse(localStorage.getItem(STORAGE_KEYS.HISTORY) || '[]'); 12 | this.history = savedHistory; 13 | this.renderHistory(); 14 | } 15 | 16 | saveHistory() { 17 | localStorage.setItem(STORAGE_KEYS.HISTORY, JSON.stringify(this.history)); 18 | } 19 | 20 | addEntry(entry) { 21 | const now = new Date(); 22 | const historyEntry = { 23 | ...entry, 24 | timestamp: now.toISOString(), 25 | timeString: now.toLocaleTimeString() 26 | }; 27 | 28 | this.history.unshift(historyEntry); 29 | 30 | // only last 50 31 | if (this.history.length > 50) { 32 | this.history.pop(); 33 | } 34 | 35 | this.saveHistory(); 36 | this.renderHistory(); 37 | } 38 | 39 | renderHistory() { 40 | this.historyList.innerHTML = ''; 41 | 42 | this.history.forEach(entry => { 43 | const historyItem = document.createElement('div'); 44 | historyItem.className = 'history-item'; 45 | 46 | const mainInfo = document.createElement('div'); 47 | mainInfo.className = 'history-main-info'; 48 | mainInfo.innerHTML = ` 49 | ${entry.timeString} 50 | ${entry.action} ${entry.sessionName} 51 | `; 52 | 53 | const details = document.createElement('div'); 54 | details.className = 'history-details'; 55 | 56 | if (entry.duration) { 57 | details.innerHTML += `Duration: ${entry.duration}`; 58 | } 59 | 60 | if (entry.note) { 61 | details.innerHTML += `${entry.note}`; 62 | } 63 | 64 | historyItem.appendChild(mainInfo); 65 | if (details.innerHTML) { 66 | historyItem.appendChild(details); 67 | } 68 | 69 | this.historyList.appendChild(historyItem); 70 | }); 71 | } 72 | 73 | clearHistory() { 74 | this.history = []; 75 | this.saveHistory(); 76 | this.renderHistory(); 77 | } 78 | } 79 | 80 | export const historyManager = new HistoryManager(); -------------------------------------------------------------------------------- /public/css/components/buttons.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | display: flex; 3 | gap: 1rem; 4 | justify-content: center; 5 | margin-bottom: 2rem; 6 | } 7 | 8 | .btn { 9 | padding: 0.8rem 2rem; 10 | font-size: 1rem; 11 | border: none; 12 | border-radius: 50px; 13 | cursor: pointer; 14 | font-weight: 600; 15 | transition: all 0.3s ease; 16 | text-transform: uppercase; 17 | letter-spacing: 1px; 18 | } 19 | 20 | .btn-primary { 21 | background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); 22 | color: white; 23 | box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); 24 | } 25 | 26 | .btn-secondary { 27 | background: rgba(255, 255, 255, 0.1); 28 | color: white; 29 | border: 1px solid rgba(255, 255, 255, 0.2); 30 | } 31 | 32 | .btn:hover { 33 | transform: translateY(-2px); 34 | box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); 35 | } 36 | 37 | .btn:disabled { 38 | opacity: 0.5; 39 | cursor: not-allowed; 40 | transform: none; 41 | } 42 | 43 | .settings-toggle { 44 | background: none; 45 | border: none; 46 | color: var(--text-color); 47 | cursor: pointer; 48 | width: 100%; 49 | padding: 1rem; 50 | font-size: 1.2rem; 51 | opacity: 0.7; 52 | transition: all 0.3s ease; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | border-top: 1px solid var(--border-color); 57 | } 58 | 59 | .settings-toggle:hover { 60 | opacity: 1; 61 | background: rgba(255, 255, 255, 0.05); 62 | } 63 | 64 | .settings-toggle i { 65 | transition: transform 0.3s ease; 66 | } 67 | 68 | .home-button { 69 | display: inline-block; 70 | padding: 1rem 2rem; 71 | font-size: 1.1rem; 72 | text-decoration: none; 73 | color: white; 74 | background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); 75 | border-radius: 50px; 76 | transition: all 0.3s ease; 77 | box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); 78 | } 79 | 80 | .home-button:hover { 81 | transform: translateY(-2px); 82 | box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); 83 | } 84 | 85 | .coffee-link { 86 | display: inline-flex; 87 | align-items: center; 88 | gap: 0.5rem; 89 | padding: 0.5rem 1rem; 90 | background: #FFDD00; 91 | color: #000000; 92 | border-radius: 1rem; 93 | text-decoration: none; 94 | font-weight: 600; 95 | transition: all 0.3s ease; 96 | } 97 | 98 | .coffee-link:hover { 99 | background: #FFE44D; 100 | transform: translateY(-2px); 101 | box-shadow: 0 2px 8px rgba(255, 221, 0, 0.3); 102 | } 103 | 104 | .coffee-link i { 105 | font-size: 1.1em; 106 | } -------------------------------------------------------------------------------- /public/css/components/settings.css: -------------------------------------------------------------------------------- 1 | .settings-panel { 2 | margin-top: 3rem; 3 | padding-top: 2rem; 4 | border-top: 1px solid var(--border-color); 5 | text-align: left; 6 | max-height: 2000px; 7 | opacity: 1; 8 | overflow: hidden; 9 | transition: all 0.5s ease; 10 | } 11 | 12 | .settings-panel.collapsed { 13 | max-height: 0; 14 | opacity: 0; 15 | margin-top: 0; 16 | padding-top: 0; 17 | } 18 | 19 | .settings-panel.collapsed .setting-group, 20 | .settings-panel.collapsed .session-history { 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | .settings-panel h2 { 26 | font-size: 1.8rem; 27 | margin-bottom: 1.5rem; 28 | color: var(--primary-color); 29 | } 30 | 31 | .setting-group { 32 | background: rgba(0, 0, 0, 0.2); 33 | border-radius: 10px; 34 | padding: 1.5rem; 35 | margin-bottom: 1.5rem; 36 | } 37 | 38 | .setting-group h3 { 39 | font-size: 1.2rem; 40 | margin-bottom: 1rem; 41 | color: var(--secondary-color); 42 | } 43 | 44 | .setting-item { 45 | margin-bottom: 1rem; 46 | display: flex; 47 | align-items: center; 48 | gap: 1rem; 49 | } 50 | 51 | .text-input { 52 | background: var(--input-bg); 53 | border: 1px solid var(--border-color); 54 | border-radius: 5px; 55 | padding: 0.5rem 1rem; 56 | color: white; 57 | width: 100%; 58 | font-size: 1rem; 59 | } 60 | 61 | .time-setting { 62 | display: flex; 63 | align-items: center; 64 | } 65 | 66 | .time-inputs { 67 | display: flex; 68 | align-items: center; 69 | gap: 0.5rem; 70 | } 71 | 72 | .time-input { 73 | background: var(--input-bg); 74 | border: 1px solid var(--border-color); 75 | border-radius: 5px; 76 | padding: 0.5rem; 77 | color: white; 78 | width: 60px; 79 | text-align: center; 80 | font-size: 1rem; 81 | } 82 | 83 | .checkbox-label { 84 | display: flex; 85 | align-items: center; 86 | gap: 0.5rem; 87 | cursor: pointer; 88 | } 89 | 90 | .text-area { 91 | background: var(--input-bg); 92 | border: 1px solid var(--border-color); 93 | border-radius: 5px; 94 | padding: 0.8rem; 95 | color: white; 96 | width: 100%; 97 | min-height: 80px; 98 | resize: vertical; 99 | font-family: inherit; 100 | font-size: 0.9rem; 101 | line-height: 1.5; 102 | } 103 | 104 | .text-area:focus { 105 | outline: none; 106 | border-color: var(--primary-color); 107 | box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); 108 | } 109 | 110 | .setting-help { 111 | display: block; 112 | font-size: 0.8rem; 113 | color: rgba(255, 255, 255, 0.6); 114 | margin-top: 0.3rem; 115 | font-style: italic; 116 | } -------------------------------------------------------------------------------- /public/css/404.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #6366f1; 3 | --secondary-color: #ec4899; 4 | --background-start: #1e1e2e; 5 | --background-end: #2d2d44; 6 | --text-color: #ffffff; 7 | } 8 | 9 | * { 10 | margin: 0; 11 | padding: 0; 12 | box-sizing: border-box; 13 | } 14 | 15 | body { 16 | font-family: 'Montserrat', sans-serif; 17 | background: linear-gradient(135deg, var(--background-start), var(--background-end)); 18 | min-height: 100vh; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | color: var(--text-color); 23 | padding: 2rem; 24 | } 25 | 26 | .container { 27 | text-align: center; 28 | max-width: 600px; 29 | padding: 2rem; 30 | } 31 | 32 | .error-code { 33 | font-size: 8rem; 34 | font-weight: 600; 35 | background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); 36 | -webkit-background-clip: text; 37 | -webkit-text-fill-color: transparent; 38 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 39 | animation: pulse 2s infinite; 40 | } 41 | 42 | .error-message { 43 | font-size: 1.5rem; 44 | margin: 1.5rem 0; 45 | opacity: 0.9; 46 | } 47 | 48 | .error-description { 49 | font-size: 1.1rem; 50 | margin-bottom: 2rem; 51 | opacity: 0.7; 52 | line-height: 1.6; 53 | } 54 | 55 | .timer-animation { 56 | width: 150px; 57 | height: 150px; 58 | border: 4px solid rgba(255, 255, 255, 0.1); 59 | border-top: 4px solid var(--primary-color); 60 | border-right: 4px solid var(--secondary-color); 61 | border-radius: 50%; 62 | margin: 2rem auto; 63 | animation: spin 2s linear infinite; 64 | } 65 | 66 | .home-button { 67 | display: inline-block; 68 | padding: 1rem 2rem; 69 | font-size: 1.1rem; 70 | text-decoration: none; 71 | color: white; 72 | background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); 73 | border-radius: 50px; 74 | transition: all 0.3s ease; 75 | box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); 76 | } 77 | 78 | .home-button:hover { 79 | transform: translateY(-2px); 80 | box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); 81 | } 82 | 83 | @keyframes pulse { 84 | 0% { opacity: 1; } 85 | 50% { opacity: 0.7; } 86 | 100% { opacity: 1; } 87 | } 88 | 89 | @keyframes spin { 90 | 0% { transform: rotate(0deg); } 91 | 100% { transform: rotate(360deg); } 92 | } 93 | 94 | @media (max-width: 600px) { 95 | .error-code { 96 | font-size: 6rem; 97 | } 98 | 99 | .error-message { 100 | font-size: 1.2rem; 101 | } 102 | 103 | .error-description { 104 | font-size: 1rem; 105 | } 106 | 107 | .timer-animation { 108 | width: 100px; 109 | height: 100px; 110 | } 111 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PulseTimer 🤖 2 | 3 | A modern work timer with AI-powered recommendations. Get smart timer settings based on your goals, plus beautiful design and focus features. 4 | 5 | 🌐 **[Try PulseTimer Online](https://timer.toxi360.org)** 6 | 7 | ![Timer Interface](img/timer.png) 8 | ![AI Assistant](img/ai-assistant.png) 9 | ![Break Time](img/breaktime.png) 10 | ![Settings Panel](img/settings.png) 11 | 12 | ## ✨ Features 13 | 14 | - **🤖 AI Assistant** - Describe your goal, get smart timer recommendations 15 | - **🎯 8 Presets** - Study, Deep Work, Creative, Coding, Meeting, Writing, Research, Exercise 16 | - **⏱️ Flexible Timer** - Custom work/break intervals, session names, progress tracking 17 | - **🔔 Smart Notifications** - Desktop alerts and audio feedback 18 | - **🎨 Modern Design** - Clean interface, responsive for all devices 19 | - **📊 Session History** - Track your daily productivity 20 | - **🔒 Privacy Focused** - All data stays local, timer hide mode 21 | 22 | ## 🚀 Quick Start 23 | 24 | ### Online Usage 25 | Visit [timer.toxi360.org](https://timer.toxi360.org) to use PulseTimer directly in your browser. No installation required! 26 | 27 | ### Local Installation 28 | 29 | 1. **Clone the repository:** 30 | ```bash 31 | git clone https://github.com/Efeckc17/PulseTimer.git 32 | cd PulseTimer 33 | ``` 34 | 35 | 2. **Install dependencies:** 36 | ```bash 37 | npm install 38 | ``` 39 | 40 | 3. **Set up AI (optional):** 41 | ```bash 42 | echo "OPENROUTER_API_KEY=your_key_here" > .env 43 | ``` 44 | 45 | 4. **Start the server:** 46 | ```bash 47 | npm start 48 | ``` 49 | 50 | 5. **Open your browser and visit:** 51 | ``` 52 | http://localhost:5678 53 | ``` 54 | 55 | **AI Setup (Optional):** Get a free API key from [OpenRouter](https://openrouter.ai) for AI recommendations. Works fine without it too! 56 | 57 | ## 🛠️ Tech Stack 58 | 59 | - **Frontend**: HTML5, CSS3, JavaScript (ES6 modules) 60 | - **Backend**: Node.js, Express 61 | - **AI**: OpenRouter API (GPT-3.5-turbo) 62 | - **Audio**: Web Audio API 63 | - **Notifications**: Web Notifications API 64 | - **Security**: Helmet.js 65 | 66 | ## 🚀 Usage 67 | 68 | 1. **AI Assistant**: Click the floating button → describe your goal → get recommendations 69 | 2. **Manual Setup**: Set work duration, break intervals, session name 70 | 3. **Start Timer**: Play/pause/reset, track progress 71 | 4. **Smart Breaks**: Auto breaks with notes, skip if needed 72 | 5. **History**: Check your daily sessions in settings 73 | 74 | 75 | ## 🤝 Contributing 76 | 77 | Found a bug? Want a feature? PRs welcome! 78 | 79 | 1. Fork the repo 80 | 2. Make your changes 81 | 3. Submit a PR 82 | 83 | ## 💝 Support 84 | 85 | - ⭐ Star the repo 86 | - ☕ [Buy me a coffee](https://buymeacoffee.com/toxi360) 87 | - 🐛 Report issues 88 | 89 | ## 📜 License 90 | 91 | PulseTimer is licensed under the [Apache License 2.0](LICENSE). 92 | 93 | --- 94 | 95 | **Made with ❤️ by [@Efeckc17](https://github.com/Efeckc17)** 96 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const helmet = require('helmet'); 4 | const path = require('path'); 5 | 6 | const app = express(); 7 | const PORT = 5678; 8 | 9 | app.use(helmet({ 10 | contentSecurityPolicy: { 11 | directives: { 12 | defaultSrc: ["'self'"], 13 | scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 14 | styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdnjs.cloudflare.com"], 15 | fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"], 16 | imgSrc: ["'self'", "data:", "https:"], 17 | connectSrc: ["'self'", "https://openrouter.ai"] 18 | } 19 | } 20 | })); 21 | 22 | app.use(express.static(path.join(__dirname, 'public'))); 23 | app.use(express.json()); 24 | 25 | app.get('/', (req, res) => { 26 | res.sendFile(path.join(__dirname, 'public', 'index.html')); 27 | }); 28 | 29 | app.post('/api/ai/analyze', async (req, res) => { 30 | try { 31 | const { prompt, type, maxTokens, temperature } = req.body; 32 | 33 | if (!prompt) { 34 | return res.status(400).json({ error: 'Prompt is required' }); 35 | } 36 | 37 | if (!process.env.OPENROUTER_API_KEY) { 38 | return res.status(500).json({ error: 'API key not configured' }); 39 | } 40 | 41 | const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { 42 | method: 'POST', 43 | headers: { 44 | 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, 45 | 'Content-Type': 'application/json', 46 | 'HTTP-Referer': req.get('origin') || 'http://localhost:5678', 47 | 'X-Title': 'PulseTimer AI' 48 | }, 49 | body: JSON.stringify({ 50 | model: 'openai/gpt-3.5-turbo', 51 | messages: [ 52 | { 53 | role: 'user', 54 | content: prompt 55 | } 56 | ], 57 | max_tokens: maxTokens || 150, 58 | temperature: temperature || 0.7 59 | }) 60 | }); 61 | 62 | if (!response.ok) { 63 | throw new Error(`API error: ${response.status}`); 64 | } 65 | 66 | const data = await response.json(); 67 | const result = data.choices[0].message.content.trim(); 68 | 69 | res.json({ result }); 70 | } catch (error) { 71 | console.error('AI API Error:', error); 72 | res.status(500).json({ 73 | error: 'AI service temporarily unavailable', 74 | fallback: true 75 | }); 76 | } 77 | }); 78 | 79 | app.use((req, res, next) => { 80 | res.status(404).sendFile(path.join(__dirname, 'public', '404.html')); 81 | }); 82 | 83 | app.use((err, req, res, next) => { 84 | console.error(err.stack); 85 | res.status(500).sendFile(path.join(__dirname, 'public', '404.html')); 86 | }); 87 | 88 | app.listen(PORT, () => { 89 | console.log(`Server is running on http://localhost:${PORT}`); 90 | }); -------------------------------------------------------------------------------- /public/js/ai/config.js: -------------------------------------------------------------------------------- 1 | const AI_CONFIG = { 2 | API_ENDPOINT: '/api/ai/analyze', 3 | MODEL: 'openai/gpt-3.5-turbo', 4 | MAX_TOKENS: 150, 5 | TEMPERATURE: 0.7, 6 | STORAGE_KEY: 'pulseTimer_ai_preferences' 7 | }; 8 | 9 | const AI_PRESETS = { 10 | study: { 11 | name: 'Study Session', 12 | workMinutes: 25, 13 | breakMinutes: 5, 14 | breakInterval: 25, 15 | description: 'Perfect for focused learning and retention' 16 | }, 17 | deep_work: { 18 | name: 'Deep Work', 19 | workMinutes: 90, 20 | breakMinutes: 15, 21 | breakInterval: 90, 22 | description: 'Extended focus periods for complex tasks' 23 | }, 24 | creative: { 25 | name: 'Creative Flow', 26 | workMinutes: 45, 27 | breakMinutes: 10, 28 | breakInterval: 45, 29 | description: 'Optimal for creative and artistic work' 30 | }, 31 | coding: { 32 | name: 'Coding Sprint', 33 | workMinutes: 30, 34 | breakMinutes: 5, 35 | breakInterval: 30, 36 | description: 'Ideal for programming and development' 37 | }, 38 | meeting: { 39 | name: 'Meeting Blocks', 40 | workMinutes: 60, 41 | breakMinutes: 15, 42 | breakInterval: 60, 43 | description: 'Structured time for meetings and calls' 44 | }, 45 | writing: { 46 | name: 'Writing Session', 47 | workMinutes: 50, 48 | breakMinutes: 10, 49 | breakInterval: 50, 50 | description: 'Sustained focus for writing and content creation' 51 | }, 52 | research: { 53 | name: 'Research Mode', 54 | workMinutes: 40, 55 | breakMinutes: 8, 56 | breakInterval: 40, 57 | description: 'Deep dive into research and analysis' 58 | }, 59 | exercise: { 60 | name: 'Workout Timer', 61 | workMinutes: 20, 62 | breakMinutes: 2, 63 | breakInterval: 20, 64 | description: 'High-intensity intervals for fitness' 65 | } 66 | }; 67 | 68 | const AI_PROMPTS = { 69 | goal_analysis: `You are a productivity expert. Analyze the user's goal and recommend the most suitable timer settings. 70 | 71 | User's goal: "{goal}" 72 | 73 | Based on this goal, recommend: 74 | 1. Work session duration (in minutes) 75 | 2. Break duration (in minutes) 76 | 3. Break interval (how often to take breaks, in minutes) 77 | 4. Brief reason for these settings 78 | 79 | Respond in this exact JSON format: 80 | { 81 | "workMinutes": number, 82 | "breakMinutes": number, 83 | "breakInterval": number, 84 | "reason": "brief explanation" 85 | }`, 86 | 87 | preset_recommendation: `You are a productivity coach. The user wants to optimize their timer for: "{goal}" 88 | 89 | Available presets: study, deep_work, creative, coding, meeting, writing, research, exercise 90 | 91 | Recommend the best preset and explain why in 1-2 sentences. 92 | 93 | Respond in this exact JSON format: 94 | { 95 | "preset": "preset_name", 96 | "reason": "brief explanation" 97 | }` 98 | }; 99 | 100 | export { AI_CONFIG, AI_PRESETS, AI_PROMPTS }; -------------------------------------------------------------------------------- /public/js/audio.js: -------------------------------------------------------------------------------- 1 | import { NOTIFICATION_SOUNDS, STORAGE_KEYS } from './config.js'; 2 | 3 | class AudioManager { 4 | constructor() { 5 | this.enabled = true; 6 | this.initialized = false; 7 | this.audioContext = null; 8 | } 9 | 10 | initialize() { 11 | const preferences = JSON.parse(localStorage.getItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE) || 'null'); 12 | if (preferences) { 13 | this.enabled = preferences.sound; 14 | } 15 | 16 | document.addEventListener('click', () => { 17 | if (!this.initialized) { 18 | this.initializeAudio(); 19 | } 20 | }); 21 | 22 | const soundCheckbox = document.getElementById('soundNotification'); 23 | if (soundCheckbox) { 24 | soundCheckbox.checked = this.enabled; 25 | soundCheckbox.addEventListener('change', () => { 26 | this.enabled = soundCheckbox.checked; 27 | this.updatePreferences(); 28 | }); 29 | } 30 | } 31 | 32 | async initializeAudio() { 33 | try { 34 | this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); 35 | if (this.audioContext.state === 'suspended') { 36 | await this.audioContext.resume(); 37 | } 38 | this.initialized = true; 39 | } catch (error) { 40 | console.log('Audio initialization failed:', error); 41 | this.enabled = false; 42 | const soundCheckbox = document.getElementById('soundNotification'); 43 | if (soundCheckbox) { 44 | soundCheckbox.checked = false; 45 | } 46 | this.updatePreferences(); 47 | } 48 | } 49 | 50 | updatePreferences() { 51 | const preferences = JSON.parse(localStorage.getItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE) || '{}'); 52 | preferences.sound = this.enabled; 53 | localStorage.setItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE, JSON.stringify(preferences)); 54 | } 55 | 56 | async playSound(soundType) { 57 | if (!this.enabled) return; 58 | 59 | if (!this.initialized) { 60 | await this.initializeAudio(); 61 | } 62 | 63 | try { 64 | const audio = new Audio(soundType); 65 | const playPromise = audio.play(); 66 | 67 | if (playPromise !== undefined) { 68 | playPromise.catch(error => { 69 | console.log('Audio playback failed:', error); 70 | if (error.name === 'NotAllowedError') { 71 | this.initialized = false; 72 | } 73 | }); 74 | } 75 | } catch (e) { 76 | console.log('Audio playback failed:', e); 77 | } 78 | } 79 | 80 | playWorkStart() { 81 | return this.playSound(NOTIFICATION_SOUNDS.WORK_START); 82 | } 83 | 84 | playBreakStart() { 85 | return this.playSound(NOTIFICATION_SOUNDS.BREAK_START); 86 | } 87 | 88 | playSessionEnd() { 89 | return this.playSound(NOTIFICATION_SOUNDS.SESSION_END); 90 | } 91 | } 92 | 93 | const audioManager = new AudioManager(); 94 | 95 | export { audioManager }; -------------------------------------------------------------------------------- /public/css/components/timer.css: -------------------------------------------------------------------------------- 1 | .timer-container { 2 | background: var(--card-bg); 3 | backdrop-filter: blur(10px); 4 | border-radius: 20px; 5 | padding: 3rem; 6 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 7 | text-align: center; 8 | } 9 | 10 | .title { 11 | font-size: 2.5rem; 12 | margin-bottom: 2rem; 13 | font-weight: 600; 14 | background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); 15 | -webkit-background-clip: text; 16 | background-clip: text; 17 | -webkit-text-fill-color: transparent; 18 | color: transparent; 19 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 20 | } 21 | 22 | .timer-section { 23 | position: relative; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | gap: 1rem; 28 | } 29 | 30 | .timer-display { 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | gap: 1rem; 35 | margin-bottom: 2rem; 36 | padding: 2rem; 37 | background: rgba(0, 0, 0, 0.2); 38 | border-radius: 15px; 39 | transition: all 0.3s ease; 40 | } 41 | 42 | .timer-display.hidden { 43 | opacity: 0; 44 | pointer-events: none; 45 | } 46 | 47 | .timer-display.masked .time-section span { 48 | font-family: monospace; 49 | content: "**"; 50 | letter-spacing: 2px; 51 | } 52 | 53 | .timer-display.masked .time-section span::before { 54 | content: "**"; 55 | position: absolute; 56 | left: 50%; 57 | transform: translateX(-50%); 58 | } 59 | 60 | .time-section { 61 | display: flex; 62 | flex-direction: column; 63 | align-items: center; 64 | position: relative; 65 | } 66 | 67 | .time-section span { 68 | font-size: 4.5rem; 69 | font-weight: 300; 70 | background: linear-gradient(45deg, #ffffff, #e0e0e0); 71 | -webkit-background-clip: text; 72 | background-clip: text; 73 | -webkit-text-fill-color: transparent; 74 | color: transparent; 75 | text-shadow: 0 2px 10px rgba(255, 255, 255, 0.1); 76 | transition: transform 0.3s ease; 77 | } 78 | 79 | .time-section span.pulse { 80 | animation: numberPulse 0.3s ease-out; 81 | } 82 | 83 | .time-section label { 84 | font-size: 0.9rem; 85 | text-transform: uppercase; 86 | letter-spacing: 2px; 87 | margin-top: 0.5rem; 88 | opacity: 0.8; 89 | } 90 | 91 | .separator { 92 | font-size: 4rem; 93 | font-weight: 300; 94 | animation: separatorPulse 1s infinite; 95 | opacity: 0.5; 96 | } 97 | 98 | .session-info { 99 | margin-bottom: 2rem; 100 | display: flex; 101 | flex-direction: column; 102 | gap: 0.5rem; 103 | } 104 | 105 | .session-name { 106 | font-size: 1.5rem; 107 | font-weight: 600; 108 | color: var(--primary-color); 109 | } 110 | 111 | .session-progress { 112 | font-size: 1rem; 113 | opacity: 0.8; 114 | } 115 | 116 | .icon-button { 117 | background: none; 118 | border: none; 119 | color: var(--text-color); 120 | cursor: pointer; 121 | padding: 0.5rem; 122 | font-size: 1.2rem; 123 | opacity: 0.7; 124 | transition: all 0.3s ease; 125 | position: absolute; 126 | right: 1rem; 127 | top: 50%; 128 | transform: translateY(-50%); 129 | } 130 | 131 | .icon-button:hover { 132 | opacity: 1; 133 | transform: translateY(-50%) scale(1.1); 134 | } -------------------------------------------------------------------------------- /public/css/responsive.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 768px) { 2 | .timer-display { 3 | gap: 0.8rem; 4 | padding: 1.5rem; 5 | } 6 | 7 | .time-section span { 8 | font-size: 3.5rem; 9 | } 10 | 11 | .separator { 12 | font-size: 3rem; 13 | } 14 | } 15 | 16 | @media (max-width: 600px) { 17 | .container { 18 | padding: 0.8rem; 19 | max-width: 100%; 20 | overflow-x: hidden; 21 | } 22 | 23 | .timer-container { 24 | padding: 1.2rem; 25 | border-radius: 15px; 26 | } 27 | 28 | .timer-display { 29 | gap: 0.5rem; 30 | padding: 1rem; 31 | margin-bottom: 1.5rem; 32 | max-width: 100%; 33 | overflow: hidden; 34 | } 35 | 36 | .time-section span { 37 | font-size: 2.5rem; 38 | } 39 | 40 | .time-section label { 41 | font-size: 0.7rem; 42 | letter-spacing: 1px; 43 | } 44 | 45 | .separator { 46 | font-size: 2.5rem; 47 | } 48 | 49 | .title { 50 | font-size: 1.8rem; 51 | margin-bottom: 1.5rem; 52 | } 53 | 54 | .session-name { 55 | font-size: 1.2rem; 56 | } 57 | 58 | .controls { 59 | flex-direction: column; 60 | gap: 0.8rem; 61 | } 62 | 63 | .btn { 64 | width: 100%; 65 | padding: 0.9rem 1.5rem; 66 | } 67 | 68 | .setting-item { 69 | flex-direction: column; 70 | align-items: flex-start; 71 | } 72 | 73 | .time-inputs { 74 | width: 100%; 75 | justify-content: space-between; 76 | } 77 | 78 | .github-links { 79 | flex-direction: column; 80 | gap: 1rem; 81 | } 82 | 83 | .error-code { 84 | font-size: 6rem; 85 | } 86 | 87 | .error-message { 88 | font-size: 1.2rem; 89 | } 90 | 91 | .error-description { 92 | font-size: 1rem; 93 | } 94 | 95 | .timer-animation { 96 | width: 100px; 97 | height: 100px; 98 | } 99 | } 100 | 101 | @media (max-width: 480px) { 102 | .container { 103 | padding: 0.5rem; 104 | } 105 | 106 | .timer-container { 107 | padding: 1rem; 108 | } 109 | 110 | .timer-display { 111 | gap: 0.3rem; 112 | padding: 0.8rem; 113 | flex-wrap: nowrap; 114 | } 115 | 116 | .time-section span { 117 | font-size: 2rem; 118 | } 119 | 120 | .separator { 121 | font-size: 2rem; 122 | } 123 | 124 | .title { 125 | font-size: 1.5rem; 126 | } 127 | 128 | .time-section label { 129 | font-size: 0.6rem; 130 | margin-top: 0.2rem; 131 | } 132 | 133 | .btn { 134 | padding: 0.8rem 1rem; 135 | font-size: 0.9rem; 136 | } 137 | } 138 | 139 | @media (max-width: 375px) { 140 | .timer-display { 141 | flex-direction: column; 142 | gap: 0.5rem; 143 | text-align: center; 144 | } 145 | 146 | .separator { 147 | display: none; 148 | } 149 | 150 | .time-section { 151 | flex-direction: row; 152 | gap: 0.3rem; 153 | align-items: baseline; 154 | } 155 | 156 | .time-section span { 157 | font-size: 1.8rem; 158 | } 159 | 160 | .time-section label { 161 | font-size: 0.5rem; 162 | margin-top: 0; 163 | margin-left: 0.2rem; 164 | } 165 | } -------------------------------------------------------------------------------- /public/js/notifications.js: -------------------------------------------------------------------------------- 1 | import { STORAGE_KEYS } from './config.js'; 2 | import { audioManager } from './audio.js'; 3 | 4 | class NotificationManager { 5 | constructor() { 6 | this.desktopEnabled = true; 7 | this.initialized = false; 8 | } 9 | 10 | initialize() { 11 | this.dialog = document.getElementById('notificationDialog'); 12 | this.enableBtn = document.getElementById('enableNotifications'); 13 | this.skipBtn = document.getElementById('skipNotifications'); 14 | this.desktopCheckbox = document.getElementById('desktopNotification'); 15 | 16 | this.bindEvents(); 17 | this.loadPreferences(); 18 | 19 | if ('Notification' in window && Notification.permission === 'default') { 20 | this.showPermissionDialog(); 21 | } 22 | } 23 | 24 | bindEvents() { 25 | this.enableBtn.addEventListener('click', () => this.requestPermission()); 26 | this.skipBtn.addEventListener('click', () => this.skipNotifications()); 27 | this.desktopCheckbox.addEventListener('change', () => this.updatePreferences()); 28 | } 29 | 30 | loadPreferences() { 31 | const preferences = JSON.parse(localStorage.getItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE) || 'null'); 32 | 33 | if (preferences) { 34 | this.desktopEnabled = preferences.desktop; 35 | this.desktopCheckbox.checked = preferences.desktop; 36 | this.initialized = true; 37 | } else { 38 | this.showPermissionDialog(); 39 | } 40 | } 41 | 42 | updatePreferences() { 43 | this.desktopEnabled = this.desktopCheckbox.checked; 44 | 45 | const preferences = JSON.parse(localStorage.getItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE) || '{}'); 46 | preferences.desktop = this.desktopEnabled; 47 | localStorage.setItem(STORAGE_KEYS.NOTIFICATION_PREFERENCE, JSON.stringify(preferences)); 48 | } 49 | 50 | showPermissionDialog() { 51 | if ('Notification' in window && !this.initialized) { 52 | if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { 53 | this.dialog.classList.remove('hidden'); 54 | } else { 55 | this.desktopEnabled = Notification.permission === 'granted'; 56 | this.desktopCheckbox.checked = this.desktopEnabled; 57 | this.initialized = true; 58 | this.updatePreferences(); 59 | } 60 | } 61 | } 62 | 63 | async requestPermission() { 64 | if ('Notification' in window) { 65 | const permission = await Notification.requestPermission(); 66 | this.desktopEnabled = permission === 'granted'; 67 | this.desktopCheckbox.checked = this.desktopEnabled; 68 | } 69 | 70 | this.initialized = true; 71 | this.dialog.classList.add('hidden'); 72 | this.updatePreferences(); 73 | } 74 | 75 | skipNotifications() { 76 | this.desktopEnabled = false; 77 | this.desktopCheckbox.checked = false; 78 | this.initialized = true; 79 | this.dialog.classList.add('hidden'); 80 | this.updatePreferences(); 81 | } 82 | 83 | notify(title, options = {}) { 84 | if (this.desktopEnabled && 'Notification' in window && Notification.permission === 'granted') { 85 | new Notification(title, { 86 | icon: '/favicon.ico', 87 | ...options 88 | }); 89 | } 90 | } 91 | } 92 | 93 | const notificationManager = new NotificationManager(); 94 | 95 | document.addEventListener('DOMContentLoaded', () => { 96 | notificationManager.initialize(); 97 | audioManager.initialize(); 98 | }); 99 | 100 | export { notificationManager }; -------------------------------------------------------------------------------- /public/js/ai/goalAnalyzer.js: -------------------------------------------------------------------------------- 1 | import { aiService } from './service.js'; 2 | import { AI_PRESETS } from './config.js'; 3 | 4 | class GoalAnalyzer { 5 | constructor() { 6 | this.currentGoal = null; 7 | this.currentSettings = null; 8 | this.analysisCache = new Map(); 9 | } 10 | 11 | async processUserGoal(goal) { 12 | if (!goal || goal.trim().length < 3) { 13 | throw new Error('Please provide a more detailed goal'); 14 | } 15 | 16 | const normalizedGoal = goal.toLowerCase().trim(); 17 | 18 | if (this.analysisCache.has(normalizedGoal)) { 19 | return this.analysisCache.get(normalizedGoal); 20 | } 21 | 22 | this.currentGoal = goal; 23 | 24 | try { 25 | const [customSettings, presetRecommendation] = await Promise.all([ 26 | aiService.analyzeGoal(goal), 27 | aiService.recommendPreset(goal) 28 | ]); 29 | 30 | const analysis = { 31 | goal: goal, 32 | customSettings: customSettings, 33 | recommendedPreset: presetRecommendation, 34 | presetDetails: AI_PRESETS[presetRecommendation.preset] || AI_PRESETS.study, 35 | timestamp: Date.now() 36 | }; 37 | 38 | this.analysisCache.set(normalizedGoal, analysis); 39 | this.currentSettings = analysis; 40 | 41 | return analysis; 42 | } catch (error) { 43 | console.error('Goal analysis failed:', error); 44 | return this.getDefaultAnalysis(goal); 45 | } 46 | } 47 | 48 | getDefaultAnalysis(goal) { 49 | const defaultPreset = this.detectPresetFromKeywords(goal); 50 | 51 | return { 52 | goal: goal, 53 | customSettings: { 54 | workMinutes: defaultPreset.workMinutes, 55 | breakMinutes: defaultPreset.breakMinutes, 56 | breakInterval: defaultPreset.breakInterval, 57 | reason: 'Detected from keywords in your goal' 58 | }, 59 | recommendedPreset: { 60 | preset: this.getPresetKey(defaultPreset), 61 | reason: 'Based on keyword analysis' 62 | }, 63 | presetDetails: defaultPreset, 64 | timestamp: Date.now() 65 | }; 66 | } 67 | 68 | detectPresetFromKeywords(goal) { 69 | const goalLower = goal.toLowerCase(); 70 | 71 | if (goalLower.includes('study') || goalLower.includes('learn') || goalLower.includes('exam')) { 72 | return AI_PRESETS.study; 73 | } 74 | if (goalLower.includes('code') || goalLower.includes('program') || goalLower.includes('develop')) { 75 | return AI_PRESETS.coding; 76 | } 77 | if (goalLower.includes('write') || goalLower.includes('article') || goalLower.includes('blog')) { 78 | return AI_PRESETS.writing; 79 | } 80 | if (goalLower.includes('creative') || goalLower.includes('design') || goalLower.includes('art')) { 81 | return AI_PRESETS.creative; 82 | } 83 | if (goalLower.includes('research') || goalLower.includes('analysis') || goalLower.includes('investigate')) { 84 | return AI_PRESETS.research; 85 | } 86 | if (goalLower.includes('meeting') || goalLower.includes('call') || goalLower.includes('discussion')) { 87 | return AI_PRESETS.meeting; 88 | } 89 | if (goalLower.includes('workout') || goalLower.includes('exercise') || goalLower.includes('fitness')) { 90 | return AI_PRESETS.exercise; 91 | } 92 | if (goalLower.includes('deep') || goalLower.includes('focus') || goalLower.includes('complex')) { 93 | return AI_PRESETS.deep_work; 94 | } 95 | 96 | return AI_PRESETS.study; 97 | } 98 | 99 | getPresetKey(presetObject) { 100 | for (const [key, preset] of Object.entries(AI_PRESETS)) { 101 | if (preset === presetObject) { 102 | return key; 103 | } 104 | } 105 | return 'study'; 106 | } 107 | 108 | getCurrentAnalysis() { 109 | return this.currentSettings; 110 | } 111 | 112 | clearCache() { 113 | this.analysisCache.clear(); 114 | } 115 | } 116 | 117 | export const goalAnalyzer = new GoalAnalyzer(); -------------------------------------------------------------------------------- /public/js/ai/presetManager.js: -------------------------------------------------------------------------------- 1 | import { AI_PRESETS, AI_CONFIG } from './config.js'; 2 | import { goalAnalyzer } from './goalAnalyzer.js'; 3 | 4 | class PresetManager { 5 | constructor() { 6 | this.activePreset = null; 7 | this.customSettings = null; 8 | this.userPreferences = this.loadUserPreferences(); 9 | } 10 | 11 | async applyAIRecommendation(analysis) { 12 | const { customSettings, recommendedPreset, presetDetails } = analysis; 13 | 14 | this.activePreset = recommendedPreset.preset; 15 | this.customSettings = customSettings; 16 | 17 | this.saveUserPreferences({ 18 | lastGoal: analysis.goal, 19 | lastPreset: recommendedPreset.preset, 20 | lastSettings: customSettings, 21 | timestamp: Date.now() 22 | }); 23 | 24 | return { 25 | preset: presetDetails, 26 | custom: customSettings, 27 | applied: true 28 | }; 29 | } 30 | 31 | applyPreset(presetKey) { 32 | if (!AI_PRESETS[presetKey]) { 33 | throw new Error(`Preset '${presetKey}' not found`); 34 | } 35 | 36 | const preset = AI_PRESETS[presetKey]; 37 | this.activePreset = presetKey; 38 | 39 | return { 40 | workMinutes: preset.workMinutes, 41 | breakMinutes: preset.breakMinutes, 42 | breakInterval: preset.breakInterval, 43 | name: preset.name, 44 | description: preset.description 45 | }; 46 | } 47 | 48 | getPresetList() { 49 | return Object.entries(AI_PRESETS).map(([key, preset]) => ({ 50 | key, 51 | ...preset, 52 | isActive: this.activePreset === key 53 | })); 54 | } 55 | 56 | getActivePreset() { 57 | return this.activePreset ? AI_PRESETS[this.activePreset] : null; 58 | } 59 | 60 | getCustomSettings() { 61 | return this.customSettings; 62 | } 63 | 64 | createCustomPreset(name, settings) { 65 | const customKey = `custom_${Date.now()}`; 66 | const customPreset = { 67 | name: name, 68 | workMinutes: settings.workMinutes, 69 | breakMinutes: settings.breakMinutes, 70 | breakInterval: settings.breakInterval, 71 | description: 'Custom AI-generated preset', 72 | isCustom: true 73 | }; 74 | 75 | const savedPresets = this.getSavedCustomPresets(); 76 | savedPresets[customKey] = customPreset; 77 | this.saveCustomPresets(savedPresets); 78 | 79 | return customKey; 80 | } 81 | 82 | getSavedCustomPresets() { 83 | try { 84 | return JSON.parse(localStorage.getItem('pulseTimer_custom_presets')) || {}; 85 | } catch { 86 | return {}; 87 | } 88 | } 89 | 90 | saveCustomPresets(presets) { 91 | localStorage.setItem('pulseTimer_custom_presets', JSON.stringify(presets)); 92 | } 93 | 94 | deleteCustomPreset(presetKey) { 95 | const savedPresets = this.getSavedCustomPresets(); 96 | delete savedPresets[presetKey]; 97 | this.saveCustomPresets(savedPresets); 98 | } 99 | 100 | getAllPresets() { 101 | const builtInPresets = this.getPresetList(); 102 | const customPresets = Object.entries(this.getSavedCustomPresets()).map(([key, preset]) => ({ 103 | key, 104 | ...preset, 105 | isActive: this.activePreset === key 106 | })); 107 | 108 | return [...builtInPresets, ...customPresets]; 109 | } 110 | 111 | loadUserPreferences() { 112 | try { 113 | return JSON.parse(localStorage.getItem(AI_CONFIG.STORAGE_KEY)) || {}; 114 | } catch { 115 | return {}; 116 | } 117 | } 118 | 119 | saveUserPreferences(preferences) { 120 | this.userPreferences = { ...this.userPreferences, ...preferences }; 121 | localStorage.setItem(AI_CONFIG.STORAGE_KEY, JSON.stringify(this.userPreferences)); 122 | } 123 | 124 | getLastUsedSettings() { 125 | return this.userPreferences.lastSettings || null; 126 | } 127 | 128 | getLastGoal() { 129 | return this.userPreferences.lastGoal || null; 130 | } 131 | 132 | clearPreferences() { 133 | this.userPreferences = {}; 134 | localStorage.removeItem(AI_CONFIG.STORAGE_KEY); 135 | localStorage.removeItem('pulseTimer_custom_presets'); 136 | } 137 | } 138 | 139 | export const presetManager = new PresetManager(); -------------------------------------------------------------------------------- /public/css/components/modals.css: -------------------------------------------------------------------------------- 1 | .notification-dialog { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | z-index: 1000; 11 | animation: fadeIn 0.3s ease; 12 | } 13 | 14 | .notification-dialog.hidden { 15 | display: none; 16 | } 17 | 18 | .notification-overlay { 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | background: rgba(0, 0, 0, 0.7); 25 | backdrop-filter: blur(8px); 26 | } 27 | 28 | .notification-content { 29 | background: var(--card-bg); 30 | backdrop-filter: blur(15px); 31 | border-radius: 20px; 32 | width: 90vw; 33 | max-width: 500px; 34 | border: 1px solid rgba(255, 255, 255, 0.1); 35 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); 36 | position: relative; 37 | overflow: hidden; 38 | animation: slideUp 0.4s ease; 39 | } 40 | 41 | .notification-header { 42 | padding: 2rem 2rem 1rem 2rem; 43 | text-align: center; 44 | border-bottom: 1px solid var(--border-color); 45 | } 46 | 47 | .notification-icon { 48 | width: 80px; 49 | height: 80px; 50 | margin: 0 auto 1.5rem auto; 51 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 52 | border-radius: 50%; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | animation: pulse 2s infinite; 57 | } 58 | 59 | .notification-icon i { 60 | font-size: 2rem; 61 | color: white; 62 | } 63 | 64 | .notification-header h3 { 65 | color: var(--text-color); 66 | margin: 0 0 0.5rem 0; 67 | font-size: 1.8rem; 68 | font-weight: 600; 69 | } 70 | 71 | .notification-subtitle { 72 | color: rgba(255, 255, 255, 0.7); 73 | margin: 0; 74 | font-size: 1rem; 75 | } 76 | 77 | .notification-body { 78 | padding: 2rem; 79 | } 80 | 81 | .permission-cards { 82 | display: grid; 83 | gap: 1rem; 84 | margin-bottom: 1.5rem; 85 | } 86 | 87 | .permission-card { 88 | background: rgba(0, 0, 0, 0.2); 89 | border-radius: 12px; 90 | padding: 1.5rem; 91 | display: flex; 92 | align-items: center; 93 | gap: 1rem; 94 | border: 1px solid var(--border-color); 95 | transition: all 0.3s ease; 96 | } 97 | 98 | .permission-card:hover { 99 | background: rgba(0, 0, 0, 0.3); 100 | transform: translateY(-2px); 101 | } 102 | 103 | .card-icon { 104 | width: 50px; 105 | height: 50px; 106 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 107 | border-radius: 12px; 108 | display: flex; 109 | align-items: center; 110 | justify-content: center; 111 | flex-shrink: 0; 112 | } 113 | 114 | .card-icon i { 115 | font-size: 1.5rem; 116 | color: white; 117 | } 118 | 119 | .card-content h4 { 120 | margin: 0 0 0.5rem 0; 121 | color: var(--text-color); 122 | font-size: 1.1rem; 123 | font-weight: 600; 124 | } 125 | 126 | .card-content p { 127 | margin: 0; 128 | color: rgba(255, 255, 255, 0.7); 129 | font-size: 0.9rem; 130 | line-height: 1.4; 131 | } 132 | 133 | .privacy-note { 134 | background: rgba(34, 197, 94, 0.1); 135 | border: 1px solid rgba(34, 197, 94, 0.3); 136 | border-radius: 10px; 137 | padding: 1rem; 138 | display: flex; 139 | align-items: center; 140 | gap: 0.8rem; 141 | color: #22c55e; 142 | font-size: 0.9rem; 143 | } 144 | 145 | .privacy-note i { 146 | font-size: 1.1rem; 147 | } 148 | 149 | .notification-actions { 150 | padding: 0 2rem 2rem 2rem; 151 | display: flex; 152 | flex-direction: column; 153 | gap: 1rem; 154 | } 155 | 156 | .enable-btn { 157 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 158 | color: white; 159 | border: none; 160 | border-radius: 12px; 161 | padding: 1rem 2rem; 162 | font-size: 1.1rem; 163 | font-weight: 600; 164 | cursor: pointer; 165 | transition: all 0.3s ease; 166 | display: flex; 167 | align-items: center; 168 | justify-content: center; 169 | gap: 0.8rem; 170 | box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); 171 | } 172 | 173 | .enable-btn:hover { 174 | transform: translateY(-2px); 175 | box-shadow: 0 6px 25px rgba(99, 102, 241, 0.4); 176 | } 177 | 178 | .skip-btn { 179 | background: transparent; 180 | color: rgba(255, 255, 255, 0.7); 181 | border: 1px solid var(--border-color); 182 | border-radius: 12px; 183 | padding: 0.8rem 2rem; 184 | font-size: 1rem; 185 | cursor: pointer; 186 | transition: all 0.3s ease; 187 | } 188 | 189 | .skip-btn:hover { 190 | background: rgba(255, 255, 255, 0.1); 191 | color: var(--text-color); 192 | } 193 | 194 | @keyframes fadeIn { 195 | from { opacity: 0; } 196 | to { opacity: 1; } 197 | } 198 | 199 | @keyframes slideUp { 200 | from { 201 | transform: translateY(50px); 202 | opacity: 0; 203 | } 204 | to { 205 | transform: translateY(0); 206 | opacity: 1; 207 | } 208 | } 209 | 210 | @media (max-width: 600px) { 211 | .notification-content { 212 | width: 95vw; 213 | margin: 1rem; 214 | } 215 | 216 | .notification-header { 217 | padding: 1.5rem 1.5rem 1rem 1.5rem; 218 | } 219 | 220 | .notification-icon { 221 | width: 60px; 222 | height: 60px; 223 | margin-bottom: 1rem; 224 | } 225 | 226 | .notification-icon i { 227 | font-size: 1.5rem; 228 | } 229 | 230 | .notification-header h3 { 231 | font-size: 1.5rem; 232 | } 233 | 234 | .notification-body { 235 | padding: 1.5rem; 236 | } 237 | 238 | .permission-card { 239 | padding: 1rem; 240 | flex-direction: column; 241 | text-align: center; 242 | gap: 0.8rem; 243 | } 244 | 245 | .card-icon { 246 | width: 40px; 247 | height: 40px; 248 | } 249 | 250 | .card-icon i { 251 | font-size: 1.2rem; 252 | } 253 | 254 | .notification-actions { 255 | padding: 0 1.5rem 1.5rem 1.5rem; 256 | } 257 | 258 | .enable-btn { 259 | padding: 0.9rem 1.5rem; 260 | font-size: 1rem; 261 | } 262 | 263 | .skip-btn { 264 | padding: 0.7rem 1.5rem; 265 | font-size: 0.9rem; 266 | } 267 | } 268 | 269 | .break-overlay { 270 | position: fixed; 271 | top: 0; 272 | left: 0; 273 | right: 0; 274 | bottom: 0; 275 | background: rgba(0, 0, 0, 0.9); 276 | display: flex; 277 | align-items: center; 278 | justify-content: center; 279 | z-index: 2000; 280 | backdrop-filter: blur(8px); 281 | } 282 | 283 | .break-overlay.hidden { 284 | display: none; 285 | } 286 | 287 | .break-content { 288 | background: var(--card-bg); 289 | padding: 2rem; 290 | border-radius: 15px; 291 | text-align: center; 292 | max-width: 500px; 293 | width: 90%; 294 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 295 | } 296 | 297 | .break-content h2 { 298 | color: var(--primary-color); 299 | font-size: 2rem; 300 | margin-bottom: 1.5rem; 301 | animation: pulse 2s infinite; 302 | } 303 | 304 | .break-timer { 305 | font-size: 3rem; 306 | font-weight: bold; 307 | margin: 1.5rem 0; 308 | font-family: monospace; 309 | color: var(--accent-color); 310 | } 311 | 312 | .break-note { 313 | background: rgba(255, 255, 255, 0.1); 314 | padding: 1rem; 315 | border-radius: 8px; 316 | margin: 1.5rem 0; 317 | font-style: italic; 318 | } 319 | 320 | .break-note p { 321 | margin: 0; 322 | line-height: 1.5; 323 | } -------------------------------------------------------------------------------- /public/css/components/ai.css: -------------------------------------------------------------------------------- 1 | .ai-toggle { 2 | position: fixed; 3 | bottom: 2rem; 4 | right: 2rem; 5 | background: linear-gradient(45deg, #ff6b6b, #4ecdc4); 6 | color: white; 7 | border: none; 8 | border-radius: 50px; 9 | padding: 1rem 1.5rem; 10 | font-size: 1rem; 11 | font-weight: 600; 12 | cursor: pointer; 13 | box-shadow: 0 4px 20px rgba(255, 107, 107, 0.3); 14 | transition: all 0.3s ease; 15 | z-index: 999; 16 | display: flex; 17 | align-items: center; 18 | gap: 0.5rem; 19 | } 20 | 21 | .ai-toggle:hover { 22 | transform: translateY(-2px); 23 | box-shadow: 0 6px 25px rgba(255, 107, 107, 0.4); 24 | } 25 | 26 | .ai-toggle i { 27 | animation: pulse 2s infinite; 28 | } 29 | 30 | .ai-panel { 31 | position: fixed; 32 | top: 50%; 33 | left: 50%; 34 | transform: translate(-50%, -50%); 35 | background: var(--card-bg); 36 | backdrop-filter: blur(15px); 37 | border-radius: 20px; 38 | width: 90vw; 39 | max-width: 600px; 40 | max-height: 80vh; 41 | overflow-y: auto; 42 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); 43 | z-index: 1000; 44 | border: 1px solid rgba(255, 255, 255, 0.1); 45 | } 46 | 47 | .ai-panel.hidden { 48 | display: none; 49 | } 50 | 51 | .ai-header { 52 | display: flex; 53 | justify-content: space-between; 54 | align-items: center; 55 | padding: 1.5rem; 56 | border-bottom: 1px solid var(--border-color); 57 | } 58 | 59 | .ai-header h3 { 60 | margin: 0; 61 | color: var(--primary-color); 62 | display: flex; 63 | align-items: center; 64 | gap: 0.5rem; 65 | } 66 | 67 | .ai-close { 68 | background: none; 69 | border: none; 70 | color: var(--text-color); 71 | cursor: pointer; 72 | padding: 0.5rem; 73 | border-radius: 50%; 74 | transition: all 0.3s ease; 75 | } 76 | 77 | .ai-close:hover { 78 | background: rgba(255, 255, 255, 0.1); 79 | transform: scale(1.1); 80 | } 81 | 82 | .ai-content { 83 | padding: 1.5rem; 84 | } 85 | 86 | .ai-input-section { 87 | margin-bottom: 1.5rem; 88 | } 89 | 90 | .ai-action-section { 91 | margin-bottom: 2rem; 92 | text-align: center; 93 | } 94 | 95 | .ai-input-section label { 96 | display: block; 97 | margin-bottom: 0.5rem; 98 | font-weight: 600; 99 | color: var(--secondary-color); 100 | } 101 | 102 | .ai-input-section textarea { 103 | width: 100%; 104 | min-height: 100px; 105 | background: var(--input-bg); 106 | border: 1px solid var(--border-color); 107 | border-radius: 10px; 108 | padding: 1rem; 109 | color: var(--text-color); 110 | font-family: inherit; 111 | font-size: 1rem; 112 | resize: vertical; 113 | margin-bottom: 1rem; 114 | } 115 | 116 | .ai-input-section textarea:focus { 117 | outline: none; 118 | border-color: var(--primary-color); 119 | box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); 120 | } 121 | 122 | .ai-analyze-btn { 123 | width: 100%; 124 | padding: 1rem 1.5rem; 125 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 126 | color: white; 127 | border: none; 128 | border-radius: 12px; 129 | font-size: 1.1rem; 130 | font-weight: 600; 131 | cursor: pointer; 132 | transition: all 0.3s ease; 133 | display: flex; 134 | align-items: center; 135 | justify-content: center; 136 | gap: 0.8rem; 137 | box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); 138 | text-transform: none; 139 | letter-spacing: 0.5px; 140 | position: relative; 141 | overflow: hidden; 142 | } 143 | 144 | .ai-analyze-btn::before { 145 | content: ''; 146 | position: absolute; 147 | top: 0; 148 | left: -100%; 149 | width: 100%; 150 | height: 100%; 151 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); 152 | transition: left 0.5s; 153 | } 154 | 155 | .ai-analyze-btn:hover::before { 156 | left: 100%; 157 | } 158 | 159 | .ai-analyze-btn:hover { 160 | transform: translateY(-2px); 161 | box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4); 162 | background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%); 163 | } 164 | 165 | .ai-analyze-btn:disabled { 166 | opacity: 0.7; 167 | cursor: not-allowed; 168 | transform: none; 169 | } 170 | 171 | .ai-analyze-btn:disabled:hover { 172 | transform: none; 173 | box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); 174 | } 175 | 176 | .ai-analyze-btn i { 177 | font-size: 1.2rem; 178 | animation: none; 179 | } 180 | 181 | .ai-analyze-btn .fa-spinner { 182 | animation: spin 1s linear infinite; 183 | } 184 | 185 | .ai-analyze-btn span { 186 | font-weight: 600; 187 | } 188 | 189 | 190 | 191 | .ai-results { 192 | margin: 1.5rem 0; 193 | } 194 | 195 | .ai-recommendation h4 { 196 | margin-bottom: 1rem; 197 | color: var(--primary-color); 198 | display: flex; 199 | align-items: center; 200 | gap: 0.5rem; 201 | } 202 | 203 | .recommendation-cards { 204 | display: grid; 205 | grid-template-columns: 1fr 1fr; 206 | gap: 1rem; 207 | margin-bottom: 1.5rem; 208 | } 209 | 210 | .recommendation-card { 211 | background: rgba(0, 0, 0, 0.2); 212 | border-radius: 15px; 213 | padding: 1.5rem; 214 | border: 1px solid var(--border-color); 215 | transition: all 0.3s ease; 216 | } 217 | 218 | .recommendation-card:hover { 219 | transform: translateY(-2px); 220 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); 221 | } 222 | 223 | .recommendation-card.custom { 224 | border-left: 4px solid var(--primary-color); 225 | } 226 | 227 | .recommendation-card.preset { 228 | border-left: 4px solid var(--secondary-color); 229 | } 230 | 231 | .recommendation-card h5 { 232 | margin: 0 0 1rem 0; 233 | display: flex; 234 | align-items: center; 235 | gap: 0.5rem; 236 | color: var(--text-color); 237 | } 238 | 239 | .settings-preview { 240 | display: flex; 241 | flex-wrap: wrap; 242 | gap: 0.5rem; 243 | margin-bottom: 1rem; 244 | } 245 | 246 | .settings-preview span { 247 | background: rgba(255, 255, 255, 0.1); 248 | padding: 0.25rem 0.5rem; 249 | border-radius: 15px; 250 | font-size: 0.8rem; 251 | color: var(--text-color); 252 | } 253 | 254 | .recommendation-card .reason { 255 | font-size: 0.9rem; 256 | color: rgba(255, 255, 255, 0.8); 257 | margin-bottom: 1rem; 258 | line-height: 1.4; 259 | } 260 | 261 | .recommendation-card .apply-btn { 262 | width: 100%; 263 | padding: 0.8rem; 264 | font-size: 0.9rem; 265 | } 266 | 267 | .ai-presets-section { 268 | margin-top: 2rem; 269 | } 270 | 271 | .ai-presets-section h4 { 272 | margin-bottom: 1rem; 273 | color: var(--secondary-color); 274 | display: flex; 275 | align-items: center; 276 | gap: 0.5rem; 277 | } 278 | 279 | .ai-presets-grid { 280 | display: grid; 281 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 282 | gap: 1rem; 283 | } 284 | 285 | .preset-card { 286 | background: rgba(0, 0, 0, 0.2); 287 | border-radius: 10px; 288 | padding: 1rem; 289 | cursor: pointer; 290 | transition: all 0.3s ease; 291 | border: 1px solid var(--border-color); 292 | text-align: center; 293 | } 294 | 295 | .preset-card:hover { 296 | transform: translateY(-2px); 297 | background: rgba(0, 0, 0, 0.3); 298 | border-color: var(--primary-color); 299 | } 300 | 301 | .preset-card h5 { 302 | margin: 0 0 0.5rem 0; 303 | font-size: 0.9rem; 304 | color: var(--primary-color); 305 | } 306 | 307 | .preset-timing { 308 | font-weight: 600; 309 | margin-bottom: 0.5rem; 310 | color: var(--secondary-color); 311 | } 312 | 313 | .preset-card p { 314 | font-size: 0.8rem; 315 | color: rgba(255, 255, 255, 0.7); 316 | margin: 0; 317 | line-height: 1.3; 318 | } 319 | 320 | .ai-error { 321 | background: rgba(239, 68, 68, 0.1); 322 | border: 1px solid rgba(239, 68, 68, 0.3); 323 | border-radius: 10px; 324 | padding: 1rem; 325 | text-align: center; 326 | color: #f87171; 327 | } 328 | 329 | .ai-error i { 330 | margin-right: 0.5rem; 331 | font-size: 1.2rem; 332 | } 333 | 334 | .ai-success { 335 | position: fixed; 336 | top: 2rem; 337 | right: 2rem; 338 | background: rgba(34, 197, 94, 0.9); 339 | color: white; 340 | padding: 1rem 1.5rem; 341 | border-radius: 10px; 342 | display: flex; 343 | align-items: center; 344 | gap: 0.5rem; 345 | box-shadow: 0 4px 20px rgba(34, 197, 94, 0.3); 346 | z-index: 1001; 347 | animation: slideInRight 0.3s ease; 348 | } 349 | 350 | @keyframes slideInRight { 351 | from { 352 | transform: translateX(100%); 353 | opacity: 0; 354 | } 355 | to { 356 | transform: translateX(0); 357 | opacity: 1; 358 | } 359 | } 360 | 361 | @media (max-width: 768px) { 362 | .ai-panel { 363 | width: 95vw; 364 | max-height: 85vh; 365 | } 366 | 367 | .recommendation-cards { 368 | grid-template-columns: 1fr; 369 | } 370 | 371 | .ai-presets-grid { 372 | grid-template-columns: repeat(2, 1fr); 373 | } 374 | 375 | .ai-toggle { 376 | bottom: 1rem; 377 | right: 1rem; 378 | padding: 0.8rem 1.2rem; 379 | } 380 | } 381 | 382 | @media (max-width: 480px) { 383 | .ai-content { 384 | padding: 1rem; 385 | } 386 | 387 | .ai-header { 388 | padding: 1rem; 389 | } 390 | 391 | .recommendation-card { 392 | padding: 1rem; 393 | } 394 | 395 | .ai-presets-grid { 396 | grid-template-columns: 1fr; 397 | } 398 | 399 | .preset-card { 400 | padding: 0.8rem; 401 | } 402 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 toxi360 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /public/js/ai/ui.js: -------------------------------------------------------------------------------- 1 | import { goalAnalyzer } from './goalAnalyzer.js'; 2 | import { presetManager } from './presetManager.js'; 3 | import { AI_PRESETS } from './config.js'; 4 | 5 | class AIUI { 6 | constructor() { 7 | this.isOpen = false; 8 | this.isProcessing = false; 9 | this.currentAnalysis = null; 10 | this.initializeElements(); 11 | this.bindEvents(); 12 | } 13 | 14 | initializeElements() { 15 | this.createAIInterface(); 16 | 17 | setTimeout(() => { 18 | this.aiPanel = document.getElementById('aiPanel'); 19 | this.aiToggle = document.getElementById('aiToggle'); 20 | this.goalInput = document.getElementById('aiGoalInput'); 21 | this.analyzeBtn = document.getElementById('aiAnalyzeBtn'); 22 | this.resultsContainer = document.getElementById('aiResults'); 23 | this.presetsContainer = document.getElementById('aiPresets'); 24 | 25 | 26 | console.log('AI Elements initialized:', { 27 | aiPanel: !!this.aiPanel, 28 | aiToggle: !!this.aiToggle, 29 | goalInput: !!this.goalInput, 30 | analyzeBtn: !!this.analyzeBtn, 31 | resultsContainer: !!this.resultsContainer, 32 | presetsContainer: !!this.presetsContainer, 33 | 34 | }); 35 | }, 0); 36 | } 37 | 38 | createAIInterface() { 39 | const aiHTML = ` 40 | 44 | 45 | 74 | `; 75 | 76 | const settingsPanel = document.querySelector('.settings-panel'); 77 | settingsPanel.insertAdjacentHTML('beforebegin', aiHTML); 78 | } 79 | 80 | bindEvents() { 81 | setTimeout(() => { 82 | if (this.aiToggle) { 83 | this.aiToggle.addEventListener('click', () => this.togglePanel()); 84 | } 85 | 86 | const aiClose = document.getElementById('aiClose'); 87 | if (aiClose) { 88 | aiClose.addEventListener('click', () => this.closePanel()); 89 | } 90 | 91 | if (this.analyzeBtn) { 92 | this.analyzeBtn.addEventListener('click', () => this.analyzeGoal()); 93 | } 94 | 95 | if (this.goalInput) { 96 | this.goalInput.addEventListener('keydown', (e) => { 97 | if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { 98 | this.analyzeGoal(); 99 | } 100 | }); 101 | } 102 | 103 | this.renderPresets(); 104 | }, 50); 105 | } 106 | 107 | togglePanel() { 108 | this.isOpen = !this.isOpen; 109 | this.aiPanel.classList.toggle('hidden', !this.isOpen); 110 | 111 | if (this.isOpen) { 112 | this.goalInput.focus(); 113 | this.loadLastGoal(); 114 | } 115 | } 116 | 117 | closePanel() { 118 | this.isOpen = false; 119 | this.aiPanel.classList.add('hidden'); 120 | } 121 | 122 | loadLastGoal() { 123 | const lastGoal = presetManager.getLastGoal(); 124 | if (lastGoal && !this.goalInput.value) { 125 | this.goalInput.value = lastGoal; 126 | } 127 | } 128 | 129 | async analyzeGoal() { 130 | const goal = this.goalInput.value.trim(); 131 | 132 | if (!goal) { 133 | this.showError('Please describe what you want to focus on'); 134 | return; 135 | } 136 | 137 | if (this.isProcessing) return; 138 | 139 | this.isProcessing = true; 140 | this.analyzeBtn.disabled = true; 141 | this.analyzeBtn.innerHTML = ' Analyzing...'; 142 | 143 | try { 144 | const analysis = await goalAnalyzer.processUserGoal(goal); 145 | this.currentAnalysis = analysis; 146 | this.displayResults(analysis); 147 | } catch (error) { 148 | this.showError(error.message || 'Something went wrong. Please try again.'); 149 | } finally { 150 | this.isProcessing = false; 151 | this.analyzeBtn.disabled = false; 152 | this.analyzeBtn.innerHTML = ' Get AI Recommendations'; 153 | } 154 | } 155 | 156 | displayResults(analysis) { 157 | console.log('displayResults called with:', analysis); 158 | 159 | if (!analysis || !analysis.customSettings || !analysis.recommendedPreset) { 160 | console.error('Invalid analysis data:', analysis); 161 | this.showError('Invalid response from AI. Please try again.'); 162 | return; 163 | } 164 | 165 | const { customSettings, recommendedPreset, presetDetails } = analysis; 166 | 167 | const resultsHTML = ` 168 |
169 |

AI Recommendations

170 | 171 |
172 |
173 |
Custom Settings
174 |
175 | Work: ${customSettings.workMinutes}min 176 | Break: ${customSettings.breakMinutes}min 177 | Interval: ${customSettings.breakInterval}min 178 |
179 |

${customSettings.reason}

180 | 183 |
184 | 185 |
186 |
${presetDetails.name}
187 |
188 | Work: ${presetDetails.workMinutes}min 189 | Break: ${presetDetails.breakMinutes}min 190 | Interval: ${presetDetails.breakInterval}min 191 |
192 |

${recommendedPreset.reason}

193 | 196 |
197 |
198 |
199 | `; 200 | 201 | this.resultsContainer.innerHTML = resultsHTML; 202 | this.resultsContainer.classList.remove('hidden'); 203 | 204 | this.bindResultEvents(); 205 | } 206 | 207 | bindResultEvents() { 208 | const applyButtons = this.resultsContainer.querySelectorAll('.apply-btn'); 209 | applyButtons.forEach(btn => { 210 | btn.addEventListener('click', (e) => { 211 | const type = e.target.dataset.type; 212 | const preset = e.target.dataset.preset; 213 | this.applyRecommendation(type, preset); 214 | }); 215 | }); 216 | } 217 | 218 | applyRecommendation(type, presetKey = null) { 219 | let settings; 220 | 221 | if (type === 'custom' && this.currentAnalysis) { 222 | settings = this.currentAnalysis.customSettings; 223 | } else if (type === 'preset' && presetKey) { 224 | settings = presetManager.applyPreset(presetKey); 225 | } else { 226 | return; 227 | } 228 | 229 | this.updateTimerSettings(settings); 230 | this.showSuccess(`Applied ${type === 'custom' ? 'custom' : settings.name} settings!`); 231 | 232 | setTimeout(() => this.closePanel(), 1500); 233 | } 234 | 235 | updateTimerSettings(settings) { 236 | const workHoursInput = document.getElementById('workHours'); 237 | const workMinutesInput = document.getElementById('workMinutes'); 238 | const breakMinutesInput = document.getElementById('breakMinutes'); 239 | const breakIntervalInput = document.getElementById('breakIntervalMinutes'); 240 | const workNameInput = document.getElementById('workName'); 241 | 242 | if (workHoursInput) workHoursInput.value = Math.floor(settings.workMinutes / 60); 243 | if (workMinutesInput) workMinutesInput.value = settings.workMinutes % 60; 244 | if (breakMinutesInput) breakMinutesInput.value = settings.breakMinutes; 245 | if (breakIntervalInput) breakIntervalInput.value = settings.breakInterval || settings.workMinutes; 246 | if (workNameInput && settings.name) workNameInput.value = settings.name; 247 | 248 | const changeEvent = new Event('change', { bubbles: true }); 249 | workMinutesInput?.dispatchEvent(changeEvent); 250 | } 251 | 252 | renderPresets() { 253 | const presets = Object.entries(AI_PRESETS).slice(0, 6); 254 | 255 | const presetsHTML = presets.map(([key, preset]) => ` 256 |
257 |
${preset.name}
258 |
259 | ${preset.workMinutes}/${preset.breakMinutes}min 260 |
261 |

${preset.description}

262 |
263 | `).join(''); 264 | 265 | this.presetsContainer.innerHTML = presetsHTML; 266 | 267 | this.presetsContainer.querySelectorAll('.preset-card').forEach(card => { 268 | card.addEventListener('click', (e) => { 269 | const presetKey = e.currentTarget.dataset.preset; 270 | this.applyRecommendation('preset', presetKey); 271 | }); 272 | }); 273 | } 274 | 275 | 276 | 277 | showError(message) { 278 | this.resultsContainer.innerHTML = ` 279 |
280 | 281 |

${message}

282 |
283 | `; 284 | this.resultsContainer.classList.remove('hidden'); 285 | } 286 | 287 | showSuccess(message) { 288 | const successDiv = document.createElement('div'); 289 | successDiv.className = 'ai-success'; 290 | successDiv.innerHTML = ` 291 | 292 | ${message} 293 | `; 294 | 295 | this.aiPanel.appendChild(successDiv); 296 | 297 | setTimeout(() => { 298 | successDiv.remove(); 299 | }, 3000); 300 | } 301 | } 302 | 303 | export const aiUI = new AIUI(); -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PulseTimer - Smart Work Timer with Break Management 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 |

PulseTimer

45 | 46 |
47 |
48 |
49 | 00 50 | 51 |
52 |
:
53 |
54 | 00 55 | 56 |
57 |
:
58 |
59 | 00 60 | 61 |
62 |
63 | 66 |
67 | 68 |
69 | Work Time 70 | 71 |
72 | 73 |
74 | 75 | 76 | 77 |
78 | 79 | 82 | 83 | 172 |
173 |
174 | 175 | 187 | 188 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /public/js/timer.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SETTINGS, STORAGE_KEYS } from './config.js'; 2 | import { notificationManager } from './notifications.js'; 3 | import { audioManager } from './audio.js'; 4 | import { historyManager } from './history.js'; 5 | 6 | class Timer { 7 | constructor() { 8 | this.workSeconds = 0; 9 | this.totalWorkSeconds = 0; 10 | this.workInterval = null; 11 | this.isWorkRunning = false; 12 | 13 | this.breakSeconds = 0; 14 | this.breakInterval = null; 15 | this.isBreak = false; 16 | 17 | 18 | this.settings = { ...DEFAULT_SETTINGS }; 19 | 20 | this.initializeElements(); 21 | this.bindEventListeners(); 22 | this.loadSettings(); 23 | this.updateDisplay(); 24 | } 25 | 26 | initializeElements() { 27 | this.hoursElement = document.getElementById('hours'); 28 | this.minutesElement = document.getElementById('minutes'); 29 | this.secondsElement = document.getElementById('seconds'); 30 | 31 | this.startBtn = document.getElementById('startBtn'); 32 | this.pauseBtn = document.getElementById('pauseBtn'); 33 | this.resetBtn = document.getElementById('resetBtn'); 34 | 35 | this.workNameInput = document.getElementById('workName'); 36 | this.workHoursInput = document.getElementById('workHours'); 37 | this.workMinutesInput = document.getElementById('workMinutes'); 38 | 39 | this.breakIntervalMinutesInput = document.getElementById('breakIntervalMinutes'); 40 | this.breakMinutesInput = document.getElementById('breakMinutes'); 41 | this.breakNoteInput = document.getElementById('breakNote'); 42 | 43 | this.breakOverlay = document.getElementById('breakOverlay'); 44 | this.breakTimeLeft = document.getElementById('breakTimeLeft'); 45 | this.breakNoteDisplay = document.getElementById('breakNoteDisplay'); 46 | this.skipBreakBtn = document.getElementById('skipBreak'); 47 | 48 | this.sessionNameElement = document.getElementById('sessionName'); 49 | this.sessionProgressElement = document.getElementById('sessionProgress'); 50 | } 51 | 52 | bindEventListeners() { 53 | this.startBtn.addEventListener('click', () => this.start()); 54 | this.pauseBtn.addEventListener('click', () => this.pause()); 55 | this.resetBtn.addEventListener('click', () => this.reset()); 56 | 57 | const handleBreakSettingChange = (event) => { 58 | const input = event.target; 59 | let value = parseInt(input.value); 60 | 61 | if (isNaN(value) || value < 1) { 62 | value = 1; 63 | } else if (value > 30) { 64 | value = 30; 65 | } 66 | input.value = value; 67 | 68 | this.settings.break.minutes = value; 69 | localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(this.settings)); 70 | 71 | if (this.isBreak) { 72 | this.updateDisplay(); 73 | } 74 | }; 75 | 76 | const handleWorkSettingChange = () => { 77 | this.totalWorkSeconds = (parseInt(this.workHoursInput.value) * 3600) + 78 | (parseInt(this.workMinutesInput.value) * 60); 79 | 80 | if (!this.isWorkRunning) { 81 | this.workSeconds = this.totalWorkSeconds; 82 | this.updateDisplay(); 83 | } 84 | this.saveSettings(); 85 | }; 86 | 87 | this.breakMinutesInput.addEventListener('input', handleBreakSettingChange); 88 | this.breakMinutesInput.addEventListener('change', handleBreakSettingChange); 89 | 90 | [this.workHoursInput, this.workMinutesInput].forEach(input => { 91 | input.addEventListener('input', handleWorkSettingChange); 92 | input.addEventListener('change', handleWorkSettingChange); 93 | }); 94 | 95 | this.breakIntervalMinutesInput.addEventListener('input', () => { 96 | let value = parseInt(this.breakIntervalMinutesInput.value); 97 | if (isNaN(value) || value < 1) { 98 | value = 25; 99 | this.breakIntervalMinutesInput.value = value; 100 | } 101 | this.saveSettings(); 102 | if (!this.isWorkRunning) { 103 | this.totalWorkSeconds = this.totalWorkSeconds - (value * 60); 104 | } 105 | }); 106 | 107 | [this.workNameInput, this.breakNoteInput].forEach(input => { 108 | input.addEventListener('change', () => this.saveSettings()); 109 | }); 110 | 111 | this.skipBreakBtn.addEventListener('click', () => this.skipBreak()); 112 | } 113 | 114 | loadSettings() { 115 | const savedSettings = JSON.parse(localStorage.getItem(STORAGE_KEYS.SETTINGS) || 'null'); 116 | if (savedSettings) { 117 | this.settings = savedSettings; 118 | 119 | this.workNameInput.value = this.settings.work.name; 120 | this.workHoursInput.value = this.settings.work.hours; 121 | this.workMinutesInput.value = this.settings.work.minutes; 122 | this.breakIntervalMinutesInput.value = this.settings.break.intervalMinutes; 123 | this.breakMinutesInput.value = this.settings.break.minutes; 124 | } 125 | 126 | this.totalWorkSeconds = (parseInt(this.workHoursInput.value) * 3600) + 127 | (parseInt(this.workMinutesInput.value) * 60); 128 | this.workSeconds = this.totalWorkSeconds; 129 | } 130 | 131 | saveSettings() { 132 | let breakMinutes = parseInt(this.breakMinutesInput.value); 133 | if (isNaN(breakMinutes) || breakMinutes < 1) { 134 | breakMinutes = 5; 135 | this.breakMinutesInput.value = 5; 136 | } 137 | 138 | this.settings = { 139 | work: { 140 | name: this.workNameInput.value, 141 | hours: parseInt(this.workHoursInput.value) || 2, 142 | minutes: parseInt(this.workMinutesInput.value) || 0 143 | }, 144 | break: { 145 | minutes: breakMinutes, 146 | intervalMinutes: parseInt(this.breakIntervalMinutesInput.value) || 25 147 | } 148 | }; 149 | 150 | localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(this.settings)); 151 | } 152 | 153 | start() { 154 | if (!this.isWorkRunning && !this.isBreak) { 155 | this.isWorkRunning = true; 156 | this.startBtn.disabled = true; 157 | this.pauseBtn.disabled = false; 158 | 159 | notificationManager.notify( 160 | 'Work Session Started', 161 | { body: 'Your work session has started' } 162 | ); 163 | audioManager.playWorkStart(); 164 | 165 | this.workInterval = setInterval(() => { 166 | if (this.workSeconds > 0) { 167 | this.workSeconds--; 168 | this.updateDisplay(); 169 | 170 | const breakIntervalSeconds = parseInt(this.breakIntervalMinutesInput.value) * 60; 171 | if (this.workSeconds > 0 && this.workSeconds % breakIntervalSeconds === 0) { 172 | this.startBreak(); 173 | } 174 | } else { 175 | this.sessionComplete(); 176 | } 177 | }, 1000); 178 | 179 | historyManager.addEntry({ 180 | action: 'Started Work', 181 | sessionName: this.workNameInput.value, 182 | duration: this.formatTime(this.workSeconds) 183 | }); 184 | } 185 | } 186 | 187 | pause() { 188 | if (this.isWorkRunning) { 189 | this.isWorkRunning = false; 190 | this.startBtn.disabled = false; 191 | this.pauseBtn.disabled = true; 192 | clearInterval(this.workInterval); 193 | 194 | historyManager.addEntry({ 195 | action: 'Paused', 196 | sessionName: this.workNameInput.value, 197 | duration: this.formatTime(this.workSeconds) 198 | }); 199 | } 200 | } 201 | 202 | reset() { 203 | this.pause(); 204 | this.workSeconds = this.totalWorkSeconds; 205 | 206 | if (this.isBreak) { 207 | clearInterval(this.breakInterval); 208 | this.isBreak = false; 209 | this.breakOverlay.classList.add('hidden'); 210 | } 211 | 212 | this.updateDisplay(); 213 | 214 | historyManager.addEntry({ 215 | action: 'Reset', 216 | sessionName: this.workNameInput.value 217 | }); 218 | } 219 | 220 | startBreak() { 221 | clearInterval(this.workInterval); 222 | this.isBreak = true; 223 | 224 | this.breakSeconds = parseInt(this.breakMinutesInput.value) * 60; 225 | this.breakNoteDisplay.textContent = this.breakNoteInput.value || 'Take a refreshing break!'; 226 | this.breakOverlay.classList.remove('hidden'); 227 | 228 | this.updateBreakDisplay(); 229 | this.breakInterval = setInterval(() => { 230 | if (this.breakSeconds > 0) { 231 | this.breakSeconds--; 232 | this.updateBreakDisplay(); 233 | } else { 234 | this.endBreak(); 235 | } 236 | }, 1000); 237 | 238 | notificationManager.notify( 239 | 'Break Time!', 240 | { body: this.breakNoteInput.value || 'Time for a short break!' } 241 | ); 242 | audioManager.playBreakStart(); 243 | 244 | historyManager.addEntry({ 245 | action: 'Started Break', 246 | sessionName: this.workNameInput.value, 247 | note: this.breakNoteInput.value 248 | }); 249 | } 250 | 251 | endBreak() { 252 | clearInterval(this.breakInterval); 253 | this.isBreak = false; 254 | this.breakOverlay.classList.add('hidden'); 255 | 256 | if (this.workSeconds > 0) { 257 | this.isWorkRunning = true; 258 | this.workInterval = setInterval(() => { 259 | if (this.workSeconds > 0) { 260 | this.workSeconds--; 261 | this.updateDisplay(); 262 | 263 | const breakIntervalSeconds = parseInt(this.breakIntervalMinutesInput.value) * 60; 264 | if (this.workSeconds > 0 && this.workSeconds % breakIntervalSeconds === 0) { 265 | this.startBreak(); 266 | } 267 | } else { 268 | this.sessionComplete(); 269 | } 270 | }, 1000); 271 | } 272 | 273 | notificationManager.notify( 274 | 'Break Complete!', 275 | { body: 'Time to get back to work!' } 276 | ); 277 | 278 | historyManager.addEntry({ 279 | action: 'Ended Break', 280 | sessionName: this.workNameInput.value 281 | }); 282 | } 283 | 284 | skipBreak() { 285 | clearInterval(this.breakInterval); 286 | this.isBreak = false; 287 | this.breakOverlay.classList.add('hidden'); 288 | 289 | if (this.workSeconds > 0) { 290 | this.isWorkRunning = true; 291 | this.workInterval = setInterval(() => { 292 | if (this.workSeconds > 0) { 293 | this.workSeconds--; 294 | this.updateDisplay(); 295 | 296 | 297 | const breakIntervalSeconds = parseInt(this.breakIntervalMinutesInput.value) * 60; 298 | if (this.workSeconds > 0 && this.workSeconds % breakIntervalSeconds === 0) { 299 | this.startBreak(); 300 | } 301 | } else { 302 | this.sessionComplete(); 303 | } 304 | }, 1000); 305 | } 306 | 307 | historyManager.addEntry({ 308 | action: 'Skipped Break', 309 | sessionName: this.workNameInput.value 310 | }); 311 | } 312 | 313 | sessionComplete() { 314 | this.pause(); 315 | notificationManager.notify( 316 | 'Session Complete!', 317 | { body: 'Great job! You\'ve completed your work session.' } 318 | ); 319 | audioManager.playSessionEnd(); 320 | 321 | historyManager.addEntry({ 322 | action: 'Completed', 323 | sessionName: this.workNameInput.value, 324 | duration: this.formatTime(this.totalWorkSeconds) 325 | }); 326 | } 327 | 328 | updateDisplay() { 329 | const hours = Math.floor(this.workSeconds / 3600); 330 | const minutes = Math.floor((this.workSeconds % 3600) / 60); 331 | const seconds = this.workSeconds % 60; 332 | 333 | this.hoursElement.textContent = this.padNumber(hours); 334 | this.minutesElement.textContent = this.padNumber(minutes); 335 | this.secondsElement.textContent = this.padNumber(seconds); 336 | 337 | this.updateSessionInfo(); 338 | } 339 | 340 | updateBreakDisplay() { 341 | const minutes = Math.floor(this.breakSeconds / 60); 342 | const seconds = this.breakSeconds % 60; 343 | if (this.breakTimeLeft) { 344 | this.breakTimeLeft.textContent = `${this.padNumber(minutes)}:${this.padNumber(seconds)}`; 345 | } 346 | } 347 | 348 | updateSessionInfo() { 349 | this.sessionNameElement.textContent = this.isBreak ? 'Break Time' : this.workNameInput.value; 350 | const progress = ((this.totalWorkSeconds - this.workSeconds) / this.totalWorkSeconds * 100).toFixed(1); 351 | this.sessionProgressElement.textContent = `Progress: ${progress}%`; 352 | } 353 | 354 | formatTime(seconds) { 355 | const h = Math.floor(seconds / 3600); 356 | const m = Math.floor((seconds % 3600) / 60); 357 | const s = seconds % 60; 358 | return `${this.padNumber(h)}:${this.padNumber(m)}:${this.padNumber(s)}`; 359 | } 360 | 361 | padNumber(number) { 362 | return number.toString().padStart(2, '0'); 363 | } 364 | } 365 | 366 | export const timer = new Timer(); --------------------------------------------------------------------------------