feat(notifications): per-user Do Not Disturb / quiet hours (LC-88) #177

Merged
nrupard merged 1 commit from feat/lc-88-dnd-quiet-hours into main 2026-05-22 17:11:30 +02:00
Owner

What

Per-user Do Not Disturb (LC-88): suppress out-of-app notifications on a recurring schedule (quiet hours in the user's own timezone) or a manual timed pause.

How it works

  • Storage. Auth migration 0021 adds dnd_schedule_json (recurring weekday/weekend windows + IANA timezone) and dnd_paused_until (manual pause instant) to users. The pause auto-expires by being a past instant, so there is no sweeper.
  • Decision. New pure module crate::dnd: is_suppressed(record, now) is true when a manual pause is in effect, otherwise when now falls in the schedule's weekday/weekend window for the user's local day. Windows whose end precedes their start span midnight (e.g. 22:00 to 07:00). An unknown timezone or a degenerate equal-time window fails open. 11 unit tests cover overnight wrap, weekday/weekend split, pause precedence + expiry, and instant parsing.
  • Dispatch. Web Push drops a suppressed notification (a stale toast helps no one). The email digest holds it: the tick returns without sending and without marking last_digest_sent_at, so the misses roll into the next tick once DND lifts. In-app activity (LC-82) is never gated, only out-of-app delivery.
  • Visibility. The public User gains a computed dnd_active flag, surfaced through the existing red "do not disturb" avatar badge via User::effective_status(). Wired at the DM header (others see a peer's DND) and the user's own sidebar avatar.
  • Settings UI. A Do Not Disturb section: quick-pause buttons (30m / 2h / 8h / custom minutes, capped at 7 days), a resume button, and a quiet-hours editor (timezone picker + weekday/weekend start/end times). Schedule is validated server-side; a blank/invalid timezone or a timezone with no usable window stores NULL.

Acceptance criteria

  • Recurring schedule with separate weekday/weekend windows in the user's local timezone.
  • Manual pause for a fixed duration that auto-expires.
  • During DND, Web Push is suppressed; activity entries (LC-82) still recorded.
  • During DND, the email digest holds and includes the misses in the next send.
  • DND surfaces on the avatar badge (DM header + own sidebar) so others see it.
  • DND respects the user's timezone, not the server's.
  • No urgent break-through: the referenced LC-83 is user groups, not @here/@channel urgency, so normal urgency applies and DND wins (matches the AC default).

Notes / deferred

  • LC-91 mobile push is not shipped yet, so only Web Push and the email digest consult the helper today. The single is_suppressed gate means mobile push adds one call site when it lands.
  • The DND badge reflects on the DM header and own sidebar avatar (the surfaces that carry a full User). Message-row author avatars still show stored presence status; widening that projection is a follow-up.
  • Open questions from the ticket (per-channel exception list, vacation auto-responder) are out of scope for v1.

Test

just check, just test, and just test-saas all green. Includes 11 new dnd unit tests; auth migration 0021 added to all 20 test pool helpers (17 array-form, 3 verbose-form) per the test-maintenance notes in CLAUDE.md.

🤖 Generated with Claude Code

## What Per-user Do Not Disturb (LC-88): suppress out-of-app notifications on a recurring schedule (quiet hours in the user's own timezone) or a manual timed pause. ## How it works - **Storage.** Auth migration `0021` adds `dnd_schedule_json` (recurring weekday/weekend windows + IANA timezone) and `dnd_paused_until` (manual pause instant) to `users`. The pause auto-expires by being a past instant, so there is no sweeper. - **Decision.** New pure module `crate::dnd`: `is_suppressed(record, now)` is true when a manual pause is in effect, otherwise when `now` falls in the schedule's weekday/weekend window for the user's local day. Windows whose end precedes their start span midnight (e.g. 22:00 to 07:00). An unknown timezone or a degenerate equal-time window fails open. 11 unit tests cover overnight wrap, weekday/weekend split, pause precedence + expiry, and instant parsing. - **Dispatch.** Web Push drops a suppressed notification (a stale toast helps no one). The email digest holds it: the tick returns without sending and without marking `last_digest_sent_at`, so the misses roll into the next tick once DND lifts. In-app activity (LC-82) is never gated, only out-of-app delivery. - **Visibility.** The public `User` gains a computed `dnd_active` flag, surfaced through the existing red "do not disturb" avatar badge via `User::effective_status()`. Wired at the DM header (others see a peer's DND) and the user's own sidebar avatar. - **Settings UI.** A Do Not Disturb section: quick-pause buttons (30m / 2h / 8h / custom minutes, capped at 7 days), a resume button, and a quiet-hours editor (timezone picker + weekday/weekend start/end times). Schedule is validated server-side; a blank/invalid timezone or a timezone with no usable window stores NULL. ## Acceptance criteria - [x] Recurring schedule with separate weekday/weekend windows in the user's local timezone. - [x] Manual pause for a fixed duration that auto-expires. - [x] During DND, Web Push is suppressed; activity entries (LC-82) still recorded. - [x] During DND, the email digest holds and includes the misses in the next send. - [x] DND surfaces on the avatar badge (DM header + own sidebar) so others see it. - [x] DND respects the user's timezone, not the server's. - [x] No urgent break-through: the referenced LC-83 is user groups, not @here/@channel urgency, so normal urgency applies and DND wins (matches the AC default). ## Notes / deferred - LC-91 mobile push is not shipped yet, so only Web Push and the email digest consult the helper today. The single `is_suppressed` gate means mobile push adds one call site when it lands. - The DND badge reflects on the DM header and own sidebar avatar (the surfaces that carry a full `User`). Message-row author avatars still show stored presence status; widening that projection is a follow-up. - Open questions from the ticket (per-channel exception list, vacation auto-responder) are out of scope for v1. ## Test `just check`, `just test`, and `just test-saas` all green. Includes 11 new `dnd` unit tests; auth migration `0021` added to all 20 test pool helpers (17 array-form, 3 verbose-form) per the test-maintenance notes in CLAUDE.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(notifications): per-user Do Not Disturb / quiet hours (LC-88)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Kingfisher (push) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 7s
check-secrets / TruffleHog (pull_request) Successful in 10s
check-secrets / Kingfisher (pull_request) Successful in 12s
Check / clippy + fmt + tests (pull_request) Successful in 3m59s
Create release / Create release from merged PR (pull_request) Has been skipped
8eced21a6f
Adds per-user Do Not Disturb so out-of-app notifications can be suppressed on a recurring schedule (quiet hours in the user's own timezone) or a manual timed pause, Slack-style.

New auth migration 0021 adds dnd_schedule_json (recurring weekday/weekend windows + IANA timezone) and dnd_paused_until (manual pause instant) to users. The pause auto-expires by being in the past, so no sweeper is needed.

A new pure, timezone-aware crate::dnd module owns the decision: is_suppressed(record, now) returns true when a manual pause is in effect, else when now falls inside the schedule's weekday/weekend window for the user's local day. Windows whose end precedes their start span midnight; an unknown timezone or degenerate equal-time window fails open (no suppression). Eleven unit tests cover the overnight wrap, weekday/weekend split, pause precedence + expiry, and the bare-SQLite-instant parse.

Dispatch wiring: Web Push drops a suppressed notification (a real-time toast is pointless after the fact), while the email digest holds it. The digest tick returns without sending and without marking last_digest_sent_at, so the same misses roll into the next tick once DND lifts. In-app activity records (LC-82) are untouched: DND only gates out-of-app delivery, never history.

Visibility: the public User projection gains a computed dnd_active flag (evaluated from the record at projection time), surfaced as the existing red "do not disturb" avatar badge via User::effective_status(). Wired at the DM header (so others see a peer's DND) and the user's own sidebar avatar.

Settings UI: a Do Not Disturb section with quick-pause buttons (30m / 2h / 8h / custom minutes, capped at 7 days), a resume button, and a quiet-hours editor (timezone picker + weekday/weekend start-end times). The schedule is validated server-side: a blank/invalid timezone or a timezone with no usable window stores NULL.

LC-91 mobile push is not yet shipped, so only Web Push and the email digest consult the helper today; the single is_suppressed gate means mobile push slots in with one call site. There is no urgent-mention break-through path (the referenced LC-83 turned out to be user groups, not @here/@channel urgency), so normal urgency applies and DND wins, per the acceptance criteria.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-88-dnd-quiet-hours 2026-05-22 17:11:30 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!177
No description provided.