From ef1b71d3d2266dbc302d7ce1648a3f36afc4be7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 23:36:42 +0000 Subject: [PATCH] feat(quiet-hours): let the pet hush overnight Add a daily do-not-disturb window. Inside it the pet stays silent - no meow/purr and no desktop toast - while the speech bubble still appears, so a reminder that lands overnight is waiting when you look. - quiet-hours.js: a pure clock check, shared by the config schema and the notify path, that handles a window wrapping past midnight and treats start === end as an empty window (never) rather than always. - config.js: normalize quietHours { on, start, end } with HH:MM validation and a 22:00->08:00 default. - main.js: gate sound + OS toast at the single notify() choke point, with an opts.ignoreQuiet escape hatch for anything urgent. - settings: a toggle and From/To time pickers in the Sound card. - tests for the window maths and the config normalization. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01F4gosv8NMKDptN5pTUUz7w --- src/config.js | 7 +++++ src/main.js | 10 +++++-- src/quiet-hours.js | 28 ++++++++++++++++++ src/settings-renderer.js | 8 +++++ src/settings.html | 7 +++++ tests/quiet-hours.test.js | 62 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/quiet-hours.js create mode 100644 tests/quiet-hours.test.js 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 @@
sound
+ +
+ + + + +
diff --git a/tests/quiet-hours.test.js b/tests/quiet-hours.test.js new file mode 100644 index 0000000..47545b4 --- /dev/null +++ b/tests/quiet-hours.test.js @@ -0,0 +1,62 @@ +// Quiet Hours: the pet's daily do-not-disturb window. Two things are worth +// pinning down and easy to get subtly wrong - the clock maths for a window that +// wraps past midnight, and that the config schema only ever hands main.js a valid +// { on, start, end }. Both are pure, so neither needs Electron. +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { inQuietHours, toMinutes } = require(path.join(ROOT, 'src', 'quiet-hours.js')); +const { normalize } = require(path.join(ROOT, 'src', 'config.js')); + +// A Date fixed at a given wall-clock time (only the fields inQuietHours reads). +const at = (h, m) => new Date(2020, 0, 1, h, m, 0); + +test('toMinutes parses valid HH:MM and rejects the rest', () => { + assert.strictEqual(toMinutes('00:00'), 0); + assert.strictEqual(toMinutes('08:30'), 510); + assert.strictEqual(toMinutes('23:59'), 1439); + for (const bad of ['24:00', '8:30', '22:60', '', 'nope', null, undefined]) { + assert.strictEqual(toMinutes(bad), null, `${bad} should not parse`); + } +}); + +test('a window that wraps past midnight (22:00 -> 08:00)', () => { + const q = { on: true, start: '22:00', end: '08:00' }; + assert.strictEqual(inQuietHours(q, at(23, 0)), true); // late night + assert.strictEqual(inQuietHours(q, at(3, 0)), true); // small hours + assert.strictEqual(inQuietHours(q, at(22, 0)), true); // closed at the start + assert.strictEqual(inQuietHours(q, at(8, 0)), false); // open at the end + assert.strictEqual(inQuietHours(q, at(7, 59)), true); // one minute before the end + assert.strictEqual(inQuietHours(q, at(12, 0)), false); // midday +}); + +test('a same-day window (09:00 -> 17:00)', () => { + const q = { on: true, start: '09:00', end: '17:00' }; + assert.strictEqual(inQuietHours(q, at(12, 0)), true); + assert.strictEqual(inQuietHours(q, at(9, 0)), true); + assert.strictEqual(inQuietHours(q, at(17, 0)), false); + assert.strictEqual(inQuietHours(q, at(8, 59)), false); + assert.strictEqual(inQuietHours(q, at(23, 0)), false); +}); + +test('off, and an empty (start === end) window, are never quiet', () => { + assert.strictEqual(inQuietHours({ on: false, start: '22:00', end: '08:00' }, at(2, 0)), false); + assert.strictEqual(inQuietHours({ on: true, start: '10:00', end: '10:00' }, at(10, 0)), false); + assert.strictEqual(inQuietHours({ on: true, start: '10:00', end: '10:00' }, at(3, 0)), false); + assert.strictEqual(inQuietHours(null, at(2, 0)), false); +}); + +test('quietHours config normalizes (defaults, on flag, invalid times)', () => { + assert.deepStrictEqual(normalize({}).quietHours, { on: false, start: '22:00', end: '08:00' }); + assert.deepStrictEqual( + normalize({ quietHours: { on: true, start: '23:15', end: '06:45' } }).quietHours, + { on: true, start: '23:15', end: '06:45' }); + // Garbage times fall back to the defaults; a truthy `on` is coerced to a bool. + assert.deepStrictEqual( + normalize({ quietHours: { on: 1, start: '9:9', end: 'noon' } }).quietHours, + { on: true, start: '22:00', end: '08:00' }); + // A non-object quietHours is replaced wholesale, not carried through. + assert.deepStrictEqual(normalize({ quietHours: 'yes' }).quietHours, { on: false, start: '22:00', end: '08:00' }); +});