feat(night-light): optional auto-on schedule (time window)
Night Light was all-or-nothing. Add an optional schedule with From/To time inputs so the warm overlay only shows during set hours and toggles itself on/off automatically (the overlay re-checks every minute; no reload). Overnight windows that wrap midnight (e.g. 21:00 -> 07:00) are handled. Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default 21:00-07:00). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { parseHHMM, isWithinTimeWindow } from './timeWindow';
|
||||
|
||||
const at = (h: number, m: number): Date => new Date(2026, 0, 5, h, m);
|
||||
|
||||
test('parseHHMM: valid values', () => {
|
||||
assert.equal(parseHHMM('00:00'), 0);
|
||||
assert.equal(parseHHMM('07:30'), 7 * 60 + 30);
|
||||
assert.equal(parseHHMM('23:59'), 23 * 60 + 59);
|
||||
});
|
||||
|
||||
test('parseHHMM: malformed values return null', () => {
|
||||
assert.equal(parseHHMM(''), null);
|
||||
assert.equal(parseHHMM('7:30'), null);
|
||||
assert.equal(parseHHMM('24:00'), null);
|
||||
assert.equal(parseHHMM('12:60'), null);
|
||||
assert.equal(parseHHMM('abc'), null);
|
||||
});
|
||||
|
||||
test('isWithinTimeWindow: same-day window [09:00, 17:00)', () => {
|
||||
assert.equal(isWithinTimeWindow('09:00', '17:00', at(8, 59)), false);
|
||||
assert.equal(isWithinTimeWindow('09:00', '17:00', at(9, 0)), true);
|
||||
assert.equal(isWithinTimeWindow('09:00', '17:00', at(16, 59)), true);
|
||||
assert.equal(isWithinTimeWindow('09:00', '17:00', at(17, 0)), false); // end exclusive
|
||||
});
|
||||
|
||||
test('isWithinTimeWindow: overnight window [21:00, 07:00) wraps midnight', () => {
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(20, 59)), false);
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(21, 0)), true);
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(23, 59)), true);
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(0, 0)), true);
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(6, 59)), true);
|
||||
assert.equal(isWithinTimeWindow('21:00', '07:00', at(7, 0)), false); // end exclusive
|
||||
});
|
||||
|
||||
test('isWithinTimeWindow: zero-length window is never active', () => {
|
||||
assert.equal(isWithinTimeWindow('08:00', '08:00', at(8, 0)), false);
|
||||
assert.equal(isWithinTimeWindow('08:00', '08:00', at(12, 0)), false);
|
||||
});
|
||||
|
||||
test('isWithinTimeWindow: malformed inputs are inactive', () => {
|
||||
assert.equal(isWithinTimeWindow('', '07:00', at(3, 0)), false);
|
||||
assert.equal(isWithinTimeWindow('21:00', 'nope', at(3, 0)), false);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// Parse an "HH:mm" (24h) string into minutes-since-midnight, or null if malformed.
|
||||
export function parseHHMM(value: string): number | null {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!m) return null;
|
||||
const hours = Number(m[1]);
|
||||
const minutes = Number(m[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
// Is `now`'s wall-clock time inside the [start, end) window? The window may wrap
|
||||
// past midnight (start > end, e.g. 21:00 → 07:00). A zero-length window
|
||||
// (start === end) is treated as never active. Malformed inputs → false.
|
||||
export function isWithinTimeWindow(start: string, end: string, now: Date = new Date()): boolean {
|
||||
const s = parseHHMM(start);
|
||||
const e = parseHHMM(end);
|
||||
if (s === null || e === null || s === e) return false;
|
||||
const cur = now.getHours() * 60 + now.getMinutes();
|
||||
if (s < e) return cur >= s && cur < e;
|
||||
// Overnight wrap: active from start until midnight, then midnight until end.
|
||||
return cur >= s || cur < e;
|
||||
}
|
||||
Reference in New Issue
Block a user