To-Do List + Pomodoro Timer (Ctrl+T)

Manages tasks with a Pomodoro timer, accessible via Ctrl+T.

Pada tanggal 13 Mei 2025. Lihat %(latest_version_link).

// ==UserScript==
// @name         To-Do List + Pomodoro Timer (Ctrl+T)
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Manages tasks with a Pomodoro timer, accessible via Ctrl+T.
// @author       kq
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_KEY = 'kq_todo_pomodoro_tasks';
    let tasks = [];
    let showExpired = false;
    let timerInterval = null;
    let timeLeft = 0; // in seconds
    let currentTaskForTimer = null; // Stores the object of the task being timed
    let isTimerPaused = false;
    let selectedTaskIndexForPanel = -1; // Index of task selected in management panel

    // --- Audio Alarm ---
    let audioContext;
    let alarmSoundBuffer;
    const alarmFrequency = 440; // A4 note
    const alarmDuration = 0.5; // seconds

    function setupAudio() {
        if (!audioContext) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)();
        }
    }

    function playAlarm() {
        if (!audioContext) setupAudio();
        if (!audioContext) return; // Still couldn't get context

        const oscillator = audioContext.createOscillator();
        const gainNode = audioContext.createGain();

        oscillator.connect(gainNode);
        gainNode.connect(audioContext.destination);

        oscillator.type = 'sine'; // sine, square, sawtooth, triangle
        oscillator.frequency.setValueAtTime(alarmFrequency, audioContext.currentTime); // Hz
        gainNode.gain.setValueAtTime(0.5, audioContext.currentTime); // Volume

        oscillator.start(audioContext.currentTime);
        oscillator.stop(audioContext.currentTime + alarmDuration);

        // Vibrate for mobile (if applicable, though this is a desktop script)
        if (navigator.vibrate) {
            navigator.vibrate(200);
        }
    }


    // --- Data Management ---
    function loadTasks() {
        const tasksJSON = GM_getValue(STORAGE_KEY, '[]');
        tasks = JSON.parse(tasksJSON);
    }

    function saveTasks() {
        GM_setValue(STORAGE_KEY, JSON.stringify(tasks));
    }

    function getTaskById(id) {
        return tasks.find(task => task.id === id);
    }

    // --- UI Elements ---
    let managementPanel, taskListDiv, floatingHud, hudText, hudTime, hudProgressCircle, hudProgressBar;

    function createManagementPanel() {
        panel = document.createElement('div');
        panel.id = 'todo-pomodoro-panel';
        panel.innerHTML = `
            <div id="panel-header">
                <h2>To-Do List & Pomodoro (Ctrl+T)</h2>
                <button id="close-panel-btn">&times;</button>
            </div>
            <div id="task-input-area">
                <input type="text" id="new-task-name" placeholder="Task Name">
                <input type="number" id="new-task-duration" placeholder="Minutes (default 25)" min="1">
                <button id="add-task-btn">Add Task</button>
            </div>
            <div id="task-filters">
                <label>
                    <input type="checkbox" id="show-expired-checkbox"> Show Expired
                </label>
            </div>
            <div id="task-list-container"></div>
            <div id="panel-timer-controls">
                <h3>Timer for Selected Task</h3>
                <div id="selected-task-name-panel">No task selected</div>
                <div id="selected-task-timer-panel">00:00</div>
                <button id="start-task-btn" disabled>Start</button>
                <button id="pause-task-btn" disabled>Pause</button>
                <button id="stop-task-btn" disabled>Stop</button>
            </div>
        `;
        document.body.appendChild(panel);
        managementPanel = panel;
        taskListDiv = panel.querySelector('#task-list-container');

        // Event Listeners for panel
        panel.querySelector('#close-panel-btn').addEventListener('click', togglePanel);
        panel.querySelector('#add-task-btn').addEventListener('click', handleAddTask);
        panel.querySelector('#new-task-name').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') handleAddTask();
        });
        panel.querySelector('#show-expired-checkbox').addEventListener('change', (e) => {
            showExpired = e.target.checked;
            renderTaskList();
        });

        panel.querySelector('#start-task-btn').addEventListener('click', handleStartPanelTimer);
        panel.querySelector('#pause-task-btn').addEventListener('click', handlePausePanelTimer);
        panel.querySelector('#stop-task-btn').addEventListener('click', handleStopPanelTimer);
    }

    function createFloatingHUD() {
        hud = document.createElement('div');
        hud.id = 'todo-pomodoro-hud';
        hud.innerHTML = `
            <div id="hud-task-info">
                <span id="hud-current-task-name">No active task</span>
                <span id="hud-completion-percentage">0%</span>
            </div>
            <div id="hud-timer-display">
                <svg id="hud-progress-svg" viewBox="0 0 36 36">
                    <path id="hud-progress-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
                          fill="none" stroke="#ddd" stroke-width="3"/>
                    <path id="hud-progress-bar" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
                          fill="none" stroke="#4CAF50" stroke-width="3" stroke-dasharray="100, 100" stroke-dashoffset="100"/>
                </svg>
                <span id="hud-time-text">25:00</span>
            </div>
        `;
        document.body.appendChild(hud);
        floatingHud = hud;
        hudText = hud.querySelector('#hud-current-task-name');
        hudTime = hud.querySelector('#hud-time-text');
        hudProgressBar = hud.querySelector('#hud-progress-bar');
        updateFloatingHUD(); // Initial render
    }

    function togglePanel() {
        if (!managementPanel) createManagementPanel(); // Create if doesn't exist
        managementPanel.style.display = managementPanel.style.display === 'block' ? 'none' : 'block';
        if (managementPanel.style.display === 'block') {
            renderTaskList(); // Refresh list when opened
            updatePanelTimerControls();
        }
    }

    // --- Task Rendering & Management ---
    function renderTaskList() {
        if (!taskListDiv) return;
        taskListDiv.innerHTML = '';
        const filteredTasks = tasks.filter(task => showExpired || !task.Expired);

        if (filteredTasks.length === 0) {
            taskListDiv.innerHTML = '<p>No tasks yet. Add one!</p>';
            return;
        }

        const ul = document.createElement('ul');
        filteredTasks.forEach((task, indexInAllTasks) => {
            // Find the original index if filtering is applied, or use a unique ID
            // For simplicity, we'll operate on task objects directly via their ID
            // The 'index' passed to handlers will be its actual index in `tasks` array.
            // Let's ensure tasks have a unique ID when created.
            const originalIndex = tasks.findIndex(t => t.id === task.id);

            const li = document.createElement('li');
            li.className = `task-item ${task.Done ? 'done' : ''} ${task.Expired ? 'expired' : ''}`;
            if (originalIndex === selectedTaskIndexForPanel) {
                li.classList.add('selected-for-panel');
            }
            li.dataset.taskId = task.id;

            li.innerHTML = `
                <span class="task-name">${task.Name} (${task.Duration} min)</span>
                <div class="task-actions">
                    <button class="complete-btn">${task.Done ? 'Undo' : 'Done'}</button>
                    <button class="expire-btn">${task.Expired ? 'Unexpire' : 'Expire'}</button>
                    <button class="delete-btn">Delete</button>
                </div>
            `;

            // Select task for panel timer
            li.addEventListener('click', (e) => {
                if (e.target.tagName !== 'BUTTON') { // Don't select if clicking a button
                    selectedTaskIndexForPanel = originalIndex;
                    currentTaskForTimer = null; // Stop any global timer
                    isTimerPaused = false;
                    timeLeft = tasks[selectedTaskIndexForPanel].Duration * 60;
                    if(timerInterval) clearInterval(timerInterval);
                    timerInterval = null;
                    renderTaskList(); // Re-render to show selection
                    updatePanelTimerControls();
                    updateTimerDisplay(timeLeft, managementPanel.querySelector('#selected-task-timer-panel'));
                    updateFloatingHUD(); // Reset floating HUD if a new task is selected for panel
                }
            });

            li.querySelector('.complete-btn').addEventListener('click', () => toggleDone(task.id));
            li.querySelector('.expire-btn').addEventListener('click', () => toggleExpired(task.id));
            li.querySelector('.delete-btn').addEventListener('click', () => deleteTask(task.id));
            ul.appendChild(li);
        });
        taskListDiv.appendChild(ul);
        updateCompletionPercentage();
    }

    function handleAddTask() {
        const nameInput = managementPanel.querySelector('#new-task-name');
        const durationInput = managementPanel.querySelector('#new-task-duration');
        const name = nameInput.value.trim();
        const duration = parseInt(durationInput.value) || 25;

        if (name === '') {
            alert('Task name cannot be empty!');
            return;
        }

        tasks.push({
            id: Date.now().toString(), // Simple unique ID
            Name: name,
            Duration: duration, // in minutes
            Done: false,
            Expired: false
        });
        saveTasks();
        renderTaskList();
        nameInput.value = '';
        durationInput.value = '';
        updateCompletionPercentage();
    }

    function toggleDone(taskId) {
        const task = getTaskById(taskId);
        if (task) {
            task.Done = !task.Done;
            if (task.Done && currentTaskForTimer && currentTaskForTimer.id === taskId) {
                // If current pomodoro task is marked done, stop the timer
                handleStopPanelTimer(true); // Pass true to indicate it's a completion stop
            }
            saveTasks();
            renderTaskList();
            updateCompletionPercentage();
        }
    }

    function toggleExpired(taskId) {
        const task = getTaskById(taskId);
        if (task) {
            task.Expired = !task.Expired;
             if (task.Expired && currentTaskForTimer && currentTaskForTimer.id === taskId) {
                // If current pomodoro task is marked expired, stop the timer
                handleStopPanelTimer(true);
            }
            saveTasks();
            renderTaskList();
            updateCompletionPercentage();
        }
    }

    function deleteTask(taskId) {
        if (confirm('Are you sure you want to delete this task?')) {
            tasks = tasks.filter(task => task.id !== taskId);
            if (currentTaskForTimer && currentTaskForTimer.id === taskId) {
                handleStopPanelTimer(true);
            }
            if (selectedTaskIndexForPanel !== -1 && tasks[selectedTaskIndexForPanel] && tasks[selectedTaskIndexForPanel].id === taskId) {
                selectedTaskIndexForPanel = -1;
            } else if (selectedTaskIndexForPanel !==-1) {
                // adjust selectedTaskIndexForPanel if an item before it was deleted
                const deletedTaskOriginalIndex = tasks.findIndex(t => t.id === taskId); // This is tricky after filter
                                                                                        // Safer to just reset selection or find by ID again
                const taskFormerlySelected = tasks[selectedTaskIndexForPanel];
                if (!taskFormerlySelected || taskFormerlySelected.id === taskId) selectedTaskIndexForPanel = -1;

            }


            saveTasks();
            renderTaskList();
            updatePanelTimerControls(); // Update panel controls as selected task might be gone
            updateCompletionPercentage();
        }
    }

    function updateCompletionPercentage() {
        const nonExpiredTasks = tasks.filter(task => !task.Expired);
        const completedNonExpired = nonExpiredTasks.filter(task => task.Done).length;
        const percentage = nonExpiredTasks.length > 0 ? Math.round((completedNonExpired / nonExpiredTasks.length) * 100) : 0;
        if (floatingHud) {
            floatingHud.querySelector('#hud-completion-percentage').textContent = `${percentage}%`;
        }
    }

    // --- Timer Logic & Display ---
    function formatTime(totalSeconds) {
        const minutes = Math.floor(totalSeconds / 60);
        const seconds = totalSeconds % 60;
        return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
    }

    function updateTimerDisplay(seconds, element) {
        if (element) {
            element.textContent = formatTime(seconds);
        }
    }

    function updateFloatingHUD() {
        if (!floatingHud) return;

        const circumference = 2 * Math.PI * 15.9155; // From SVG path radius

        if (currentTaskForTimer && !isTimerPaused) { // Timer is active and running
            hudText.textContent = `Task: ${currentTaskForTimer.Name}`;
            hudTime.textContent = formatTime(timeLeft);
            const durationInSeconds = currentTaskForTimer.Duration * 60;
            const progress = durationInSeconds > 0 ? (durationInSeconds - timeLeft) / durationInSeconds : 0;
            hudProgressBar.style.strokeDasharray = `${circumference}`;
            hudProgressBar.style.strokeDashoffset = `${circumference * (1 - progress)}`;
            hudProgressBar.style.stroke = '#4CAF50'; // Green for running
        } else if (currentTaskForTimer && isTimerPaused) { // Timer is active but paused
            hudText.textContent = `Paused: ${currentTaskForTimer.Name}`;
            hudTime.textContent = formatTime(timeLeft);
            const durationInSeconds = currentTaskForTimer.Duration * 60;
            const progress = durationInSeconds > 0 ? (durationInSeconds - timeLeft) / durationInSeconds : 0;
            hudProgressBar.style.strokeDasharray = `${circumference}`;
            hudProgressBar.style.strokeDashoffset = `${circumference * (1 - progress)}`;
            hudProgressBar.style.stroke = '#FFC107'; // Amber for paused
        } else { // No timer active or timer stopped
            hudText.textContent = "No active task";
            hudTime.textContent = "00:00"; // Or default like "25:00"
            hudProgressBar.style.strokeDasharray = `${circumference}`;
            hudProgressBar.style.strokeDashoffset = `${circumference}`; // Empty circle
            hudProgressBar.style.stroke = '#ddd'; // Default/empty color
        }
        updateCompletionPercentage();
    }


    function timerTick() {
        if (isTimerPaused) return;

        timeLeft--;
        updateTimerDisplay(timeLeft, managementPanel.querySelector('#selected-task-timer-panel'));
        updateFloatingHUD();

        if (timeLeft <= 0) {
            clearInterval(timerInterval);
            timerInterval = null;
            playAlarm();
            alert(`Time's up for task: ${currentTaskForTimer.Name}!`);

            // Automatically mark as done? Or prompt? For now, just stop.
            const task = getTaskById(currentTaskForTimer.id);
            if (task && !task.Done) { // Only mark done if not already done
                 // Optionally, mark as done:
                 // task.Done = true;
                 // saveTasks();
                 // renderTaskList();
            }
            currentTaskForTimer = null; // Clear current task
            isTimerPaused = false;
            updatePanelTimerControls();
            updateFloatingHUD();
        }
    }

    function updatePanelTimerControls() {
        if (!managementPanel) return;
        const startBtn = managementPanel.querySelector('#start-task-btn');
        const pauseBtn = managementPanel.querySelector('#pause-task-btn');
        const stopBtn = managementPanel.querySelector('#stop-task-btn');
        const taskNameDisplay = managementPanel.querySelector('#selected-task-name-panel');
        const taskTimerDisplay = managementPanel.querySelector('#selected-task-timer-panel');

        if (selectedTaskIndexForPanel !== -1 && tasks[selectedTaskIndexForPanel]) {
            const selectedTask = tasks[selectedTaskIndexForPanel];
            taskNameDisplay.textContent = `Task: ${selectedTask.Name}`;

            if (currentTaskForTimer && currentTaskForTimer.id === selectedTask.id) { // This task is the one with active timer
                startBtn.disabled = true;
                pauseBtn.disabled = false;
                stopBtn.disabled = false;
                pauseBtn.textContent = isTimerPaused ? "Resume" : "Pause";
                updateTimerDisplay(timeLeft, taskTimerDisplay);
            } else { // A task is selected, but no timer is running for IT specifically
                startBtn.disabled = false;
                pauseBtn.disabled = true;
                stopBtn.disabled = true;
                pauseBtn.textContent = "Pause";
                updateTimerDisplay(selectedTask.Duration * 60, taskTimerDisplay);
            }
        } else { // No task selected in panel
            taskNameDisplay.textContent = 'No task selected';
            updateTimerDisplay(0, taskTimerDisplay);
            startBtn.disabled = true;
            pauseBtn.disabled = true;
            stopBtn.disabled = true;
        }
    }


    function handleStartPanelTimer() {
        if (selectedTaskIndexForPanel === -1 || !tasks[selectedTaskIndexForPanel]) return;
        if (timerInterval) clearInterval(timerInterval); // Clear any existing global timer

        currentTaskForTimer = tasks[selectedTaskIndexForPanel];
        timeLeft = currentTaskForTimer.Duration * 60;
        isTimerPaused = false;

        timerInterval = setInterval(timerTick, 1000);
        updatePanelTimerControls();
        updateFloatingHUD();
    }

    function handlePausePanelTimer() {
        if (!currentTaskForTimer) return;

        isTimerPaused = !isTimerPaused;
        if (isTimerPaused) {
            // Timer is paused, no need to clear interval, just stop tick logic
        } else {
            // Resuming: if interval was cleared, restart it.
            // In current tick logic, interval runs, but tick does nothing if paused.
        }
        updatePanelTimerControls();
        updateFloatingHUD();
    }

    function handleStopPanelTimer(isSilent = false) { // isSilent to prevent alert if task is completed/deleted
        if (timerInterval) {
            clearInterval(timerInterval);
            timerInterval = null;
        }
        if (currentTaskForTimer && !isSilent) {
            // alert(`Timer for "${currentTaskForTimer.Name}" stopped.`);
        }
        // Reset timer state for the task that was active
        if (currentTaskForTimer && selectedTaskIndexForPanel !== -1 && tasks[selectedTaskIndexForPanel] && tasks[selectedTaskIndexForPanel].id === currentTaskForTimer.id) {
             timeLeft = tasks[selectedTaskIndexForPanel].Duration * 60; // Reset to its original duration
        } else {
             timeLeft = 0;
        }

        currentTaskForTimer = null;
        isTimerPaused = false;
        updatePanelTimerControls(); // Reflects that no timer is active for the selected task
        updateFloatingHUD(); // Clears the HUD
    }


    // --- Styles ---
    function addStyles() {
        GM_addStyle(`
            #todo-pomodoro-panel {
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                width: 450px;
                max-height: 80vh;
                background-color: #f9f9f9;
                border: 1px solid #ccc;
                box-shadow: 0 4px 8px rgba(0,0,0,0.1);
                z-index: 99999;
                display: none;
                flex-direction: column;
                font-family: Arial, sans-serif;
            }
            #panel-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 10px 15px;
                background-color: #eee;
                border-bottom: 1px solid #ccc;
            }
            #panel-header h2 {
                margin: 0;
                font-size: 1.2em;
            }
            #close-panel-btn {
                background: none;
                border: none;
                font-size: 1.5em;
                cursor: pointer;
            }
            #task-input-area {
                padding: 15px;
                display: flex;
                gap: 10px;
                border-bottom: 1px solid #eee;
            }
            #task-input-area input[type="text"] { flex-grow: 1; padding: 8px; }
            #task-input-area input[type="number"] { width: 120px; padding: 8px; }
            #task-input-area button { padding: 8px 12px; cursor: pointer; background-color: #4CAF50; color: white; border: none; }

            #task-filters { padding: 10px 15px; border-bottom: 1px solid #eee; }

            #task-list-container {
                padding: 10px 15px;
                overflow-y: auto;
                flex-grow: 1; /* Allows list to take available space */
            }
            #task-list-container ul { list-style: none; padding: 0; margin: 0; }
            .task-item {
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 8px 5px;
                border-bottom: 1px solid #eee;
                cursor: default; /* Default cursor for item */
            }
            .task-item.selected-for-panel { background-color: #e0e0e0; font-weight: bold; }
            .task-item:hover:not(.selected-for-panel) { background-color: #f0f0f0; }
            .task-item.done .task-name { text-decoration: line-through; color: #888; }
            .task-item.expired .task-name { color: #aaa; font-style: italic; }
            .task-name { flex-grow: 1; }
            .task-actions button { margin-left: 5px; padding: 3px 6px; cursor: pointer; font-size: 0.8em; }

            #panel-timer-controls { padding: 15px; border-top: 1px solid #ccc; text-align: center; }
            #panel-timer-controls h3 { margin-top: 0; font-size: 1em; }
            #selected-task-name-panel { margin-bottom: 5px; font-style: italic; }
            #selected-task-timer-panel { font-size: 1.8em; margin-bottom: 10px; font-weight: bold; }
            #panel-timer-controls button { padding: 8px 15px; margin: 0 5px; cursor: pointer; }
            #panel-timer-controls button:disabled { background-color: #ccc; cursor: not-allowed; }


            #todo-pomodoro-hud {
                position: fixed;
                bottom: 20px;
                right: 20px;
                background-color: rgba(255, 255, 255, 0.9);
                border: 1px solid #ccc;
                border-radius: 8px;
                padding: 10px 15px;
                box-shadow: 0 2px 5px rgba(0,0,0,0.1);
                z-index: 99998;
                display: flex;
                align-items: center;
                gap: 15px;
                font-family: Arial, sans-serif;
                min-width: 220px; /* Ensure enough space */
            }
            #hud-task-info { display: flex; flex-direction: column; flex-grow: 1; }
            #hud-current-task-name { font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px;}
            #hud-completion-percentage { font-size: 0.8em; color: #555; }
            #hud-timer-display { position: relative; width: 40px; height: 40px; }
            #hud-progress-svg { width: 100%; height: 100%; transform: rotate(-90deg); /* Start from top */ }
            #hud-progress-bg { stroke-linecap: round; }
            #hud-progress-bar { stroke-linecap: round; transition: stroke-dashoffset 0.3s linear, stroke 0.3s linear; }
            #hud-time-text {
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                font-size: 0.8em;
                font-weight: bold;
            }
        `);
    }

    // --- Initialization ---
    function init() {
        loadTasks();
        addStyles();
        createFloatingHUD(); // Create HUD first so it's always there
        // Management panel is created on first toggle

        document.addEventListener('keydown', (e) => {
            if (e.ctrlKey && e.key.toLowerCase() === 't') {
                e.preventDefault();
                togglePanel();
            }
        });
        setupAudio();
    }

    init();

})();