Add an optional overnight-off and morning-on window (#52) #125

Merged
claude-bot merged 1 commit from feat/52-morning-window into main 2026-09-04 23:41:54 +00:00
Contributor

Closes #52.

Two transitions the v1 envelope couldn't express: lights off in the small hours, back on before sunrise for early risers. Both off by default, in .env and Settings, with the morning trigger either a fixed time or an offset from sunrise.

Shaped around the two bugs this code has already had

The envelope is the most incident-prone part of the project — #106 cancelled a pending off by attributing it to the wrong calendar day, #110 compounded the dim across nights. Both were ordering and date-attribution bugs, so the design tries to make those classes hard rather than merely absent.

Midnight crossing has no special case. Later transitions fall on the next calendar day — exactly what #106 got wrong. Instead of branching, each transition is "the first occurrence of this clock time after the previous one", so an overnight-off at 00:30 lands on the following day and one at 23:45 doesn't, with no branch either way.

Out-of-order configurations are dropped, not scheduled. A morning-on at 05:30 against a 05:00 winter sunrise would switch the lights on after the off job had turned them off, and leave them burning all day. It's dropped instead, and the tests assert the ordering invariant directly rather than checking individual times.

Disabling unregisters the job. The jobstore is persistent, so merely not rescheduling would leave a stale morning-on sitting there waiting to fire.

The morning push resolves against the evening's date. At 05:30 on 26 December the lights are still showing Christmas night; the default would swap in Boxing Day partway through the night. Same reasoning get_active_times uses for the carried-forward off.

Both new jobs also revert an active quick push, per the issue's "revert at every envelope transition".

A Python trap the DST tests turned up

Worth recording, because I got it wrong first:

Subtracting two aware datetimes that share a tzinfo gives the wall-clock difference, not the elapsed one. Python assumes the offsets are equal and cancels them. So a fall-back night and an ordinary night both report 14 hours:

same-zone subtraction : 14:00:00
via UTC (real elapsed): 15:00:00

My first two duration assertions were wrong for precisely this reason and failed. They now measure via UTC, and one test pins the trap directly so nobody "simplifies" the conversion away.

The practical upshot for this codebase: any elapsed-time arithmetic on same-zone datetimes is silently wrong across a DST boundary. Nothing currently does that — the ramp uses datetime - timedelta, which is fine — but it's now documented in a test rather than folklore.

The other DST tests assert the invariant across both transitions (an hour vanishing mid-window on spring forward, an hour repeating on fall back), and that a 05:30 request still yields 05:30 on both sides rather than 04:30 or 06:30.

Verification

  • 595 backend tests pass (23 new), ruff, mypy clean
  • Frontend in node:22: tsc -b, eslint, vitest (76) clean
  • uvx pre-commit run --all-files passes

Default behaviour is unchanged: with both flags off the envelope is exactly the v1 on → dim → off.

🤖 Generated with Claude Code

Closes #52. Two transitions the v1 envelope couldn't express: lights off in the small hours, back on before sunrise for early risers. Both **off by default**, in `.env` and Settings, with the morning trigger either a fixed time or an offset from sunrise. ### Shaped around the two bugs this code has already had The envelope is the most incident-prone part of the project — #106 cancelled a pending off by attributing it to the wrong calendar day, #110 compounded the dim across nights. Both were ordering and date-attribution bugs, so the design tries to make those classes hard rather than merely absent. **Midnight crossing has no special case.** Later transitions fall on the *next* calendar day — exactly what #106 got wrong. Instead of branching, each transition is "the first occurrence of this clock time after the previous one", so an overnight-off at 00:30 lands on the following day and one at 23:45 doesn't, with no branch either way. **Out-of-order configurations are dropped, not scheduled.** A morning-on at 05:30 against a 05:00 winter sunrise would switch the lights on *after* the off job had turned them off, and leave them burning all day. It's dropped instead, and the tests assert the ordering invariant directly rather than checking individual times. **Disabling unregisters the job.** The jobstore is persistent, so merely not rescheduling would leave a stale morning-on sitting there waiting to fire. **The morning push resolves against the evening's date.** At 05:30 on 26 December the lights are still showing Christmas night; the default would swap in Boxing Day partway through the night. Same reasoning `get_active_times` uses for the carried-forward off. Both new jobs also revert an active quick push, per the issue's "revert at every envelope transition". ### A Python trap the DST tests turned up Worth recording, because I got it wrong first: **Subtracting two aware datetimes that share a `tzinfo` gives the wall-clock difference, not the elapsed one.** Python assumes the offsets are equal and cancels them. So a fall-back night and an ordinary night *both* report 14 hours: ``` same-zone subtraction : 14:00:00 via UTC (real elapsed): 15:00:00 ``` My first two duration assertions were wrong for precisely this reason and failed. They now measure via UTC, and one test pins the trap directly so nobody "simplifies" the conversion away. The practical upshot for this codebase: any elapsed-time arithmetic on same-zone datetimes is silently wrong across a DST boundary. Nothing currently does that — the ramp uses `datetime - timedelta`, which is fine — but it's now documented in a test rather than folklore. The other DST tests assert the invariant across both transitions (an hour vanishing mid-window on spring forward, an hour repeating on fall back), and that a 05:30 request still yields 05:30 on both sides rather than 04:30 or 06:30. ### Verification - 595 backend tests pass (23 new), `ruff`, `mypy` clean - Frontend in `node:22`: `tsc -b`, `eslint`, `vitest` (76) clean - `uvx pre-commit run --all-files` passes Default behaviour is unchanged: with both flags off the envelope is exactly the v1 on → dim → off. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Add an optional overnight-off and morning-on window (#52)
All checks were successful
CI / Python lint & type-check (pull_request) Successful in 1m2s
CI / Dockerfile lint (pull_request) Successful in 7s
CI / Frontend lint, test & build (pull_request) Successful in 1m9s
CI / Pre-commit hooks (pull_request) Successful in 2m15s
CI / Alembic migration check (pull_request) Successful in 1m43s
CI / Python tests (pull_request) Successful in 5m17s
CI / Docker build, health smoke & E2E (pull_request) Successful in 5m13s
18784e17e8
Two transitions the v1 envelope could not express: lights off in the small
hours, back on before sunrise for early risers. Both off by default, in
.env and Settings, with the morning trigger either a fixed time or an
offset from sunrise.

The envelope is the most incident-prone code here -- #106 cancelled a
pending off by attributing it to the wrong calendar day, #110 compounded
the dim across nights -- so the shape of this change is chosen to make
those classes of bug hard rather than merely absent.

Later transitions fall on the NEXT calendar day, which is precisely what
#106 got wrong. Rather than branching on midnight, each transition is "the
first occurrence of this clock time after the previous one", so an
overnight-off at 00:30 lands on the following day and one at 23:45 does
not, with no special case either way.

A transition that would land at or after the off is dropped rather than
scheduled. A morning-on at 05:30 against a 05:00 winter sunrise would
otherwise switch the lights on after the off job had turned them off and
leave them burning all day. Disabling the window unregisters the job as
well: the jobstore is persistent, so merely not rescheduling would leave a
stale morning-on waiting to fire.

The morning push resolves against the evening's date, not today's. At
05:30 on 26 December the lights are still showing Christmas night; the
default would swap in Boxing Day partway through. Both new jobs also
revert an active quick push, so a temporary colour cannot outlive the
window it was set in.

The DST tests turned up a Python trap worth recording: subtracting two
aware datetimes that share a tzinfo gives the WALL CLOCK difference, not
the elapsed one -- the offsets are assumed equal and cancel. A fall-back
night and an ordinary night both report 14 hours; only converting to UTC
shows the real 15. My first two duration assertions were wrong for exactly
this reason. The tests now measure via UTC and one of them pins the trap
directly, so nobody simplifies the conversion away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/52-morning-window 2026-09-04 23:41:55 +00:00
Sign in to join this conversation.
No description provided.