Daily reminders, with the restraint to keep them switched on - #9
Merged
Conversation
Push notifications are the most requested feature in every chore app I looked at, and the fastest way for one to get muted. The rules matter more than the plumbing, so they live in lib/domain/reminders as pure functions with 12 tests. Four gates, all of which must pass: the person asked to be reminded, they have something to be reminded about, their chosen hour has arrived, and they have not already been reminded today. That last gate is load-bearing beyond politeness. Because the decision is idempotent per day, the dispatcher is safe to call as often as you like - which is what lets a self-hosted instance run a plain fifteen-minute timer instead of requiring a cron daemon. Telling someone running a container on a NAS to 'also set up cron' is where they stop. The wording states the fact and stops. No exclamation marks, no counting days, no 'don't forget'. There is a test asserting it never uses words like lazy, should, or overdue - the same restraint the rest of the app shows, applied to the one surface where products usually abandon it. VAPID keys are generated on first use and stored beside the data. Asking a self-hoster to run a key-generation command and paste base64 into a compose file before notifications work is another place people give up. Environment variables still win when set. Key creation uses onConflictDoNothing and re-reads, so two requests racing on a cold start cannot end up with keys that disagree. Reminder time is per member, not per household: one person is up at six and another would rather not be spoken to until the evening. The stamp is written before sending, not after. If delivery throws, a missed reminder is a far smaller failure than notifying someone repeatedly on every subsequent tick. Dead subscriptions are pruned on 404/410 only. A timeout or a 502 from a push service is transient and must not cost someone their subscription. Client-side, permission is requested only on a button press, never on load - an unprompted dialog is the fastest route to a permanent denial that the page can never undo. Capability detection runs through useSyncExternalStore so hydration stays quiet, and the plain-HTTP case is explained rather than failing silently. Verified against the running server: 401 unauthenticated, a valid 87-char P-256 key that stays stable across calls, 400 on malformed input, and a cron pass that considered 10 members without sending anything it shouldn't. 131 tests, lint and typecheck clean.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Push notifications came out of the research as the most requested feature in every chore app — and they are also the fastest way for one to get permanently muted. So the rules matter considerably more than the plumbing, and they live in
lib/domain/remindersas pure functions with 12 tests.The rules
Four gates, all of which must pass:
That fourth gate is load-bearing well beyond politeness. Because the decision is idempotent per day, the dispatcher is safe to call as often as you like — which is exactly what lets a self-hosted instance run a plain fifteen-minute timer instead of requiring a cron daemon. Telling someone running a container on a NAS to "also set up a cron job" is the point at which they stop. There's a test asserting that property directly.
The wording
States the fact and stops. No exclamation marks, no "Don't forget!", no counting how many days something has slipped.
There is a test asserting the message never contains lazy, should, overdue, late, or behind. The rest of the app is careful never to become ammunition in a domestic argument; a notification is the single easiest place in a product to break that promise, so it's guarded explicitly.
Zero-setup keys
VAPID keys are generated on first use and stored beside the data. Asking a self-hoster to run
web-push generate-vapid-keysand paste two base64 strings into a compose file before notifications work at all is another place people give up. Environment variables still win when set.Key creation uses
onConflictDoNothingand then re-reads, so two requests racing on a cold instance can't end up with keys that disagree with what a browser was just handed.Details that matter
Capability detection runs through
useSyncExternalStorerather than an effect, so hydration stays quiet. The plain-HTTP case — a first-class deployment here — is explained in plain language rather than failing silently.Verified
Against the running server: 401 unauthenticated, a valid 87-character P-256 public key that stays stable across calls (proving it's persisted rather than regenerated per request), 400 on a malformed subscription, and a cron pass that considered 10 members and correctly sent nothing.
131 tests, lint and typecheck clean, build passes.