diff --git a/src/config.js b/src/config.js index 62fdae8..3c7e30a 100644 --- a/src/config.js +++ b/src/config.js @@ -45,6 +45,7 @@ const DEFAULTS = { lowPowerOnBattery: true, // auto-enter low power while running on battery pinnedNote: '', // fixed message pinned above the cat's head ('' = off) notifyOn: true, // also pop a Windows toast for reminders/messages + quietHours: { on: false, start: '22:00', end: '08:00' }, // daily do-not-disturb: no sound or toast inside this window pomodoro: { on: false, focusMin: 25, breakMin: 5 }, // focus/break loops + floating pixel timer lobbyJam: { on: false, mood: 'cozy' }, // synthesized lo-fi "study music" the cat plays (cozy/dreamy/upbeat/focus/rain) reminders: [], // [{ id, hhmm: 'HH:MM', message, recur, days, lastFired }] @@ -101,6 +102,12 @@ function normalize(cfg) { lowPowerOnBattery: c.lowPowerOnBattery === undefined ? true : !!c.lowPowerOnBattery, pinnedNote: String(c.pinnedNote == null ? '' : c.pinnedNote).trim().slice(0, 80), notifyOn: c.notifyOn === undefined ? true : !!c.notifyOn, + quietHours: (() => { + const q = (c.quietHours && typeof c.quietHours === 'object') ? c.quietHours : {}; + const start = HHMM.test(String(q.start || '')) ? String(q.start) : '22:00'; + const end = HHMM.test(String(q.end || '')) ? String(q.end) : '08:00'; + return { on: !!q.on, start, end }; + })(), pomodoro: (() => { const p = (c.pomodoro && typeof c.pomodoro === 'object') ? c.pomodoro : {}; return { on: !!p.on, focusMin: clampInt(p.focusMin, 5, 120, 25), breakMin: clampInt(p.breakMin, 1, 60, 5) }; diff --git a/src/main.js b/src/main.js index 9f54097..161a34a 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ const os = require('os'); const config = require('./config'); const datadir = require('./datadir'); const { fillPlaceholders } = require('./template'); +const { inQuietHours } = require('./quiet-hours'); const mail = require('./mail'); const cal = require('./cal'); const themes = require('./themes'); @@ -594,10 +595,15 @@ function notify(message, opts) { notifyRecent.set(key, now); if (notifyRecent.size > 200) { for (const k of notifyRecent.keys()) { notifyRecent.delete(k); if (notifyRecent.size <= 100) break; } } if (!opts.recap) recordNotify(opts.source, msg); // log it (but not when re-showing from the recap) + // Quiet Hours silences the pet without hiding it: the bubble still appears so a + // reminder that lands overnight is there when you look, but the meow/purr and the + // OS toast are held back. opts.ignoreQuiet is the escape hatch for anything that + // should always break through. + const quiet = !opts.ignoreQuiet && cfg && inQuietHours(cfg.quietHours, new Date()); if (opts.bubble !== false && win && !win.isDestroyed()) { - win.webContents.send('notify', { message: msg, ttl: opts.ttl || 5000, level: opts.level || 'info', sound: opts.sound !== false }); + win.webContents.send('notify', { message: msg, ttl: opts.ttl || 5000, level: opts.level || 'info', sound: opts.sound !== false && !quiet }); } - const wantOs = opts.os !== undefined ? opts.os : !(cfg && cfg.notifyOn === false); + const wantOs = (opts.os !== undefined ? opts.os : !(cfg && cfg.notifyOn === false)) && !quiet; if (wantOs) { try { if (Notification.isSupported()) new Notification({ title: opts.title || 'pixelpets', body: msg, silent: true }).show(); } catch (e) { /* toasts are best-effort */ } diff --git a/src/quiet-hours.js b/src/quiet-hours.js new file mode 100644 index 0000000..5ea37a5 --- /dev/null +++ b/src/quiet-hours.js @@ -0,0 +1,28 @@ +// Quiet Hours: a daily do-not-disturb window during which the pet stays silent - +// no meow/purr and no OS toast. This is purely a clock check, so it lives apart +// from Electron and both config.js (the schema) and main.js (the notify choke +// point) can share the exact same window maths. +const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/; + +// 'HH:MM' -> minutes since midnight (0..1439), or null if it isn't a valid time. +function toMinutes(hhmm) { + const m = HHMM.exec(String(hhmm == null ? '' : hhmm)); + return m ? Number(m[1]) * 60 + Number(m[2]) : null; +} + +// Is `date` inside the quiet window `q` ({ on, start, end } in 'HH:MM')? +// Handles a window that wraps past midnight (start > end, e.g. 22:00 -> 08:00). +// start === end is treated as an EMPTY window ("never"), not "always", so a +// mis-set pair can never silence the pet around the clock. The window is closed +// at the start and open at the end: [start, end), matching how a 22:00 -> 08:00 +// night ends the moment the clock reads 08:00. +function inQuietHours(q, date) { + if (!q || !q.on) return false; + const s = toMinutes(q.start); + const e = toMinutes(q.end); + if (s == null || e == null || s === e) return false; + const now = date.getHours() * 60 + date.getMinutes(); + return s < e ? (now >= s && now < e) : (now >= s || now < e); +} + +module.exports = { inQuietHours, toMinutes, HHMM }; diff --git a/src/settings-renderer.js b/src/settings-renderer.js index 4658908..fe0183b 100644 --- a/src/settings-renderer.js +++ b/src/settings-renderer.js @@ -116,6 +116,10 @@ function render() { $('moodOn').checked = cfg.moodOn === undefined ? true : !!cfg.moodOn; $('soundOn').checked = !!cfg.soundOn; $('notifyOn').checked = cfg.notifyOn === undefined ? true : !!cfg.notifyOn; + const quiet = cfg.quietHours || { on: false, start: '22:00', end: '08:00' }; + $('quietOn').checked = !!quiet.on; + $('quietStart').value = quiet.start || '22:00'; + $('quietEnd').value = quiet.end || '08:00'; $('volume').value = cfg.volume === undefined ? 100 : cfg.volume; $('volumeVal').textContent = ($('volume').value | 0) + '%'; $('onTop').checked = cfg.onTop === undefined ? true : !!cfg.onTop; @@ -195,6 +199,10 @@ $('butterflyOn').addEventListener('change', () => save({ butterflyOn: $('butterf $('moodOn').addEventListener('change', () => save({ moodOn: $('moodOn').checked })); $('soundOn').addEventListener('change', () => save({ soundOn: $('soundOn').checked })); $('notifyOn').addEventListener('change', () => save({ notifyOn: $('notifyOn').checked })); +const quietSave = () => save({ quietHours: { on: $('quietOn').checked, start: $('quietStart').value, end: $('quietEnd').value } }); +$('quietOn').addEventListener('change', quietSave); +$('quietStart').addEventListener('change', quietSave); +$('quietEnd').addEventListener('change', quietSave); $('volume').addEventListener('input', () => { $('volumeVal').textContent = ($('volume').value | 0) + '%'; }); $('volume').addEventListener('change', () => save({ volume: Number($('volume').value) })); $('onTop').addEventListener('change', () => save({ onTop: $('onTop').checked })); diff --git a/src/settings.html b/src/settings.html index 069c1dd..8938431 100644 --- a/src/settings.html +++ b/src/settings.html @@ -308,6 +308,13 @@