Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
ipcMain,
dialog,
screen,
powerMonitor,
nativeImage
} = require('electron');
const path = require('path');
Expand All @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -215,7 +227,7 @@ function createSettingsWindow() {

settingsWindow = new BrowserWindow({
width: 520,
height: 620,
height: 720,
resizable: false,
title: 'Cat Gatekeeper Settings',
autoHideMenuBar: true,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -335,6 +350,8 @@ function resetTimer() {
workSecondsRemaining = settings.workInterval * 60;
isBreakActive = false;
isPaused = false;
pauseReason = null;
idlePauseStartedAt = null;
closeOverlayWindows();
startTimer();
updateTrayMenu();
Expand Down Expand Up @@ -372,7 +389,8 @@ function broadcastTimerStatus() {
breakSecondsRemaining: Math.max(0, breakSecondsRemaining),
breakSecondsTotal: breakSecondsTotal,
isBreakActive,
isPaused
isPaused,
pauseReason
};

for (const win of overlayWindows) {
Expand Down Expand Up @@ -468,23 +486,72 @@ 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')}`;
}

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
// ---------------------------------------------------------------------------
Expand All @@ -509,7 +576,8 @@ function setupIPC() {
breakSecondsRemaining: Math.max(0, breakSecondsRemaining),
breakSecondsTotal,
isBreakActive,
isPaused
isPaused,
pauseReason
}));

ipcMain.on('dismiss-break', () => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion src/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ <h1>Cat Gatekeeper</h1>
</div>
</div>

<!-- Away Detection -->
<div class="setting-row">
<div class="setting-label">
<span class="label-text">Pause When Away</span>
<span class="label-desc">Pause the timer when there is no keyboard or mouse input</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" id="autoPauseOnIdle" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>

<div class="setting-row" id="idlePauseThresholdRow">
<div class="setting-label">
<span class="label-text">Away After</span>
<span class="label-desc">If away for a full break, the next work interval starts fresh</span>
</div>
<div class="setting-control">
<input type="range" id="idlePauseThreshold" min="60" max="900" value="300" step="60">
<span class="range-value" id="idlePauseThresholdValue">5 min</span>
</div>
</div>

<!-- HSE Health Info -->
<div class="health-info">
<div class="health-info-title">Health Guidelines</div>
Expand Down Expand Up @@ -155,4 +180,4 @@ <h1>Cat Gatekeeper</h1>
<script src="settings.js"></script>
</body>

</html>
</html>
29 changes: 29 additions & 0 deletions src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand All @@ -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 || '';
Expand All @@ -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 || '') ||
Expand All @@ -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 || '',
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
Loading