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

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

当前为 2025-05-13 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();

})();