diff --git a/main.js b/main.js index c9e4152..c8f6bb4 100644 --- a/main.js +++ b/main.js @@ -6,6 +6,7 @@ const { ipcMain, dialog, screen, + powerMonitor, nativeImage } = require('electron'); const path = require('path'); @@ -17,10 +18,12 @@ const fs = require('fs'); const settingsPath = path.join(app.getPath('userData'), 'settings.json'); const DEFAULT_SETTINGS = { - version: 3, // Increment when defaults change to trigger migration + version: 4, // Increment when defaults change to trigger migration workInterval: 50, // minutes (HSE: 5-10 min break per hour) breakDuration: 300, // seconds (5 minutes - HSE guideline) snoozeDuration: 300, // seconds (5 minutes default) + autoPauseOnIdle: true, + idlePauseThreshold: 180, // seconds (3 minutes) soundEnabled: false, multiMonitor: true, videoPath: '', // empty = use bundled default @@ -74,6 +77,13 @@ function migrateSettings(oldData, currentSettings) { currentSettings.snoozeDuration = DEFAULT_SETTINGS.snoozeDuration; } + // Migration from v3 to v4: Add automatic idle pause + if (version < 4) { + console.log('Adding automatic idle pause defaults (v3 → v4)'); + currentSettings.autoPauseOnIdle = DEFAULT_SETTINGS.autoPauseOnIdle; + currentSettings.idlePauseThreshold = DEFAULT_SETTINGS.idlePauseThreshold; + } + // Update version to latest currentSettings.version = DEFAULT_SETTINGS.version; @@ -147,6 +157,8 @@ let breakSecondsRemaining = 0; let breakSecondsTotal = 0; let isBreakActive = false; let isPaused = false; +let pauseReason = null; // null | 'manual' | 'idle' +let idlePauseStartedAt = null; let audioWindow = null; // persistent hidden window for sound playback // --------------------------------------------------------------------------- @@ -215,7 +227,7 @@ function createSettingsWindow() { settingsWindow = new BrowserWindow({ width: 520, - height: 620, + height: 720, resizable: false, title: 'Cat Gatekeeper Settings', autoHideMenuBar: true, @@ -253,9 +265,12 @@ function startTimer() { workSecondsRemaining = settings.workInterval * 60; isBreakActive = false; isPaused = false; + pauseReason = null; + idlePauseStartedAt = null; broadcastTimerStatus(); timerInterval = setInterval(() => { + updateIdlePauseState(); if (isPaused) return; if (!isBreakActive) { @@ -335,6 +350,8 @@ function resetTimer() { workSecondsRemaining = settings.workInterval * 60; isBreakActive = false; isPaused = false; + pauseReason = null; + idlePauseStartedAt = null; closeOverlayWindows(); startTimer(); updateTrayMenu(); @@ -372,7 +389,8 @@ function broadcastTimerStatus() { breakSecondsRemaining: Math.max(0, breakSecondsRemaining), breakSecondsTotal: breakSecondsTotal, isBreakActive, - isPaused + isPaused, + pauseReason }; for (const win of overlayWindows) { @@ -468,6 +486,12 @@ function getTimeDisplay() { const s = breakSecondsRemaining % 60; return `Break ends in ${m}:${s.toString().padStart(2, '0')}`; } + if (pauseReason === 'idle') { + return 'Paused while away'; + } + if (pauseReason === 'manual') { + return 'Paused'; + } const m = Math.floor(workSecondsRemaining / 60); const s = workSecondsRemaining % 60; return `Next break in ${m}:${s.toString().padStart(2, '0')}`; @@ -475,16 +499,59 @@ function getTimeDisplay() { function pauseTimer() { isPaused = true; + pauseReason = 'manual'; updateTrayMenu(); broadcastTimerStatus(); } function resumeTimer() { isPaused = false; + pauseReason = null; + idlePauseStartedAt = null; updateTrayMenu(); broadcastTimerStatus(); } +function updateIdlePauseState() { + const settings = loadSettings(); + + if (!settings.autoPauseOnIdle || isBreakActive) { + if (pauseReason === 'idle') { + resumeTimer(); + } + return; + } + + let idleSeconds = 0; + try { + idleSeconds = powerMonitor.getSystemIdleTime(); + } catch (_) { + return; + } + + if (pauseReason === 'idle') { + if (idleSeconds <= 1) { + const idlePauseSeconds = idlePauseStartedAt + ? Math.floor((Date.now() - idlePauseStartedAt) / 1000) + : 0; + + if (idlePauseSeconds >= settings.breakDuration) { + workSecondsRemaining = settings.workInterval * 60; + } + resumeTimer(); + } + return; + } + + if (!isPaused && idleSeconds >= settings.idlePauseThreshold) { + isPaused = true; + pauseReason = 'idle'; + idlePauseStartedAt = Date.now(); + updateTrayMenu(); + broadcastTimerStatus(); + } +} + // --------------------------------------------------------------------------- // IPC handlers // --------------------------------------------------------------------------- @@ -509,7 +576,8 @@ function setupIPC() { breakSecondsRemaining: Math.max(0, breakSecondsRemaining), breakSecondsTotal, isBreakActive, - isPaused + isPaused, + pauseReason })); ipcMain.on('dismiss-break', () => { diff --git a/package.json b/package.json index 665b071..20a75b6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ ], "scripts": { "start": "electron .", - "start:dev": "cross-env WORK_INTERVAL=1 BREAK_DURATION=15 electron .", + "start:dev": "cross-env WORK_INTERVAL=2 BREAK_DURATION=180 electron .", "pack": "electron-builder --dir", "dist": "electron-builder --win --mac --linux", "dist:mac": "electron-builder --mac", diff --git a/src/settings.html b/src/settings.html index f485554..eb222b4 100644 --- a/src/settings.html +++ b/src/settings.html @@ -62,6 +62,31 @@

Cat Gatekeeper

+ +
+
+ Pause When Away + Pause the timer when there is no keyboard or mouse input +
+
+ +
+
+ +
+
+ Away After + If away for a full break, the next work interval starts fresh +
+
+ + 5 min +
+
+
Health Guidelines
@@ -155,4 +180,4 @@

Cat Gatekeeper

- \ No newline at end of file + diff --git a/src/settings.js b/src/settings.js index 087c242..c31ea54 100644 --- a/src/settings.js +++ b/src/settings.js @@ -11,6 +11,10 @@ const breakDurationValue = document.getElementById('breakDurationValue'); const snoozeDurationInput = document.getElementById('snoozeDuration'); const snoozeDurationValue = document.getElementById('snoozeDurationValue'); + const autoPauseOnIdleInput = document.getElementById('autoPauseOnIdle'); + const idlePauseThresholdInput = document.getElementById('idlePauseThreshold'); + const idlePauseThresholdValue = document.getElementById('idlePauseThresholdValue'); + const idlePauseThresholdRow = document.getElementById('idlePauseThresholdRow'); const soundEnabledInput = document.getElementById('soundEnabled'); const multiMonitorInput = document.getElementById('multiMonitor'); const selectVideoBtn = document.getElementById('selectVideoBtn'); @@ -49,6 +53,11 @@ snoozeDurationInput.value = settings.snoozeDuration || 300; snoozeDurationValue.textContent = `${Math.round((settings.snoozeDuration || 300) / 60)} min`; + autoPauseOnIdleInput.checked = settings.autoPauseOnIdle !== false; + idlePauseThresholdInput.value = settings.idlePauseThreshold || 300; + idlePauseThresholdValue.textContent = `${Math.round((settings.idlePauseThreshold || 300) / 60)} min`; + idlePauseThresholdRow.style.opacity = settings.autoPauseOnIdle !== false ? '1' : '0.35'; + soundEnabledInput.checked = settings.soundEnabled; multiMonitorInput.checked = settings.multiMonitor; @@ -75,6 +84,8 @@ const currentWorkInterval = parseInt(workIntervalInput.value, 10); const currentBreakDuration = parseInt(breakDurationInput.value, 10); const currentSnoozeDuration = parseInt(snoozeDurationInput.value, 10); + const currentAutoPauseOnIdle = autoPauseOnIdleInput.checked; + const currentIdlePauseThreshold = parseInt(idlePauseThresholdInput.value, 10); const currentSoundEnabled = soundEnabledInput.checked; const currentMultiMonitor = multiMonitorInput.checked; const currentVideoPath = selectedVideoPath || ''; @@ -85,6 +96,8 @@ currentWorkInterval !== currentSettings.workInterval || currentBreakDuration !== currentSettings.breakDuration || currentSnoozeDuration !== (currentSettings.snoozeDuration || 300) || + currentAutoPauseOnIdle !== (currentSettings.autoPauseOnIdle !== false) || + currentIdlePauseThreshold !== (currentSettings.idlePauseThreshold || 300) || currentSoundEnabled !== currentSettings.soundEnabled || currentMultiMonitor !== currentSettings.multiMonitor || currentVideoPath !== (currentSettings.videoPath || '') || @@ -105,6 +118,8 @@ workInterval: parseInt(workIntervalInput.value, 10), breakDuration: parseInt(breakDurationInput.value, 10), snoozeDuration: parseInt(snoozeDurationInput.value, 10), + autoPauseOnIdle: autoPauseOnIdleInput.checked, + idlePauseThreshold: parseInt(idlePauseThresholdInput.value, 10), soundEnabled: soundEnabledInput.checked, multiMonitor: multiMonitorInput.checked, videoPath: selectedVideoPath || '', @@ -145,6 +160,9 @@ if (data.isBreakActive) { statusValue.textContent = `Break ends in ${formatTime(data.breakSecondsRemaining)}`; statusValue.style.color = '#d4a373'; + } else if (data.pauseReason === 'idle') { + statusValue.textContent = `Paused while away, ${formatTime(data.workSecondsRemaining)} left`; + statusValue.style.color = '#f39c12'; } else if (data.isPaused) { statusValue.textContent = 'Paused'; statusValue.style.color = '#e74c3c'; @@ -185,6 +203,17 @@ checkForChanges(); }); + autoPauseOnIdleInput.addEventListener('change', () => { + const enabled = autoPauseOnIdleInput.checked; + idlePauseThresholdRow.style.opacity = enabled ? '1' : '0.35'; + checkForChanges(); + }); + + idlePauseThresholdInput.addEventListener('input', () => { + idlePauseThresholdValue.textContent = `${Math.round(idlePauseThresholdInput.value / 60)} min`; + checkForChanges(); + }); + // Save saveBtn.addEventListener('click', saveSettings);