Notifications
27 kinds across every module, all computed on the device, all still firing a week after you last opened the app. This is the most involved thing in Dawnlet, and the constraint that shapes it is a number: 64.
§The problem
The previous implementation sent two reminders. On every launch it cancelled them and scheduled the next single occurrence of each.
If you didn't reopen the app, exactly one more notification ever fired, and then it went permanently silent. An app whose reminders only work if you're already opening it has the causality backwards — the reminder is supposed to be what brings you back.
Everything below follows from fixing that properly, under a constraint that can't be negotiated: iOS allows 64 pending notification requests per app, and silently drops any beyond that. No error, no warning. The 65th simply never fires.
1The horizon
Instead of the next occurrence, Dawnlet keeps 14 days of concrete, individually-scheduled notifications armed at once. Close the app for a week and they keep arriving, with no network and no server involved.
A planning pass does five things in order:
The planner is deliberately separate from anything to do with the user interface, because it has to run during a background launch where no screen has ever existed. A planner that needs a view to have been created is a planner that only works while you're looking at it.
2The 64-slot budget
Four tiers, allocated in priority order, with unused capacity cascading downward:
| Tier | Holds | Slots |
|---|---|---|
| A — anchors | Three repeating daily/weekly notifications with generic copy | 3 |
| B — reserve | Held back for event-driven kinds armed between planning passes | 8 |
| C — dated | High-priority dated items: event reminders, birthdays, bills due, todos due | 38 |
| D — deferred | Digests, streak-at-risk, overdue nudges, planning prompts | 15 |
Within the budget, items sort by priority and then by fire date, and are admitted until each tier runs out. The pass then records how far it actually got, so the settings screen can say "scheduled through October 8" instead of implying a horizon it didn't achieve.
Someone with thirty events in a fortnight exhausts the dated tier early, so their horizon is shorter than fourteen days. That's the right failure: a person with a packed calendar is a person who opens the app often, so it gets topped up anyway. The alternative — dropping their event reminders to preserve a nominal fourteen days — would be strictly worse.
Per-kind daily caps stop one noisy module from consuming the budget. Three events in a day is three notifications; ten events is one that says "10 events today". A global daily ceiling applies on top, dropping lowest priority first.
3The anchors
Three notifications — the morning digest, the evening review and the weekly recap — are scheduled as genuinely repeating triggers rather than dated instances.
That forces their copy to be generic, and the reason is mechanical: a repeating trigger reuses one piece of content forever. "3 habits due today" would be right on the first day and wrong every day after.
They're the insurance policy. If background refresh never runs — it's opportunistic, and can be switched off system-wide — every dated notification eventually runs out. The anchors don't. Someone who never reopens the app and has background refresh disabled still gets a daily nudge, forever. Everything else degrades to silence; these three can't.
4Diffing, not rescheduling
The old implementation cancelled everything and rescheduled on each pass. That's impossible to keep a delivery log against — you can't distinguish "this fired" from "I just deleted it".
So each planned notification gets an identifier that includes its fire date. Re-planning an unchanged item produces a byte-identical identifier, so the diff is empty and the scheduled request is never touched. Only genuinely new or changed items are written, and only stale ones are removed.
Two details that make it safe:
- It only ever removes identifiers in its own namespace, so a future feature can schedule its own notifications without this engine deleting them.
- Daily anchors key on their time alone, not a date — otherwise they'd look different on every pass and churn constantly.
A second pass with no data change should issue zero writes and zero removals. That's the property the whole design is checked against.
5Event-driven kinds
Four kinds are never planned by the horizon pass, because the planner would never see them:
| Kind | Armed by |
|---|---|
| Timer still running | Starting or resuming a timer |
| Timer paused too long | Pausing one |
| Streak milestone | Checking off the habit that hits the milestone |
| Inactivity nudge | Re-armed every time the app comes to the foreground |
Each depends on an action rather than on data — but at the instant of that action, the fire date is exactly computable. That's what the reserved tier of slots exists for.
The inactivity nudge is the tidiest thing in the whole system: re-arming it on every foreground means it can only fire if you genuinely haven't opened the app. No evaluation, no data fetching, no dependence on background refresh.
6Quiet hours
Applied centrally, never inside individual planners, and with two different behaviours:
Wrap-around windows (22:00 to 07:00) are handled in one place, because "is this time inside that range" is exactly the check that gets written subtly wrong in three different files.
Notification times are built without pinning a timezone, so they stay at the intended wall-clock time across travel and daylight-saving changes. The exception is a reminder set relative to an event's actual start: there, the absolute moment is computed first, because "30 minutes before" means 30 minutes, not 30 minutes plus a timezone shift.
7The feed
The dashboard's bell opens a log of what you were actually notified about. Getting that right requires solving a genuinely awkward problem.
A notification fires while the app isn't running. There is no code of yours executing. So "delivered" can't be written when it happens — it has to be reconstructed afterwards.
So the device keeps a local write-ahead log. The scheduler records entries as it arms them, and every later pass reconciles:
- Ask iOS what's still sitting in Notification Centre, and mark those delivered with their real timestamps. Accurate, but only covers what you haven't cleared.
- Otherwise: an entry whose time has passed and which is no longer pending must have fired. The "no longer pending" check is what distinguishes fired from rescheduled to later.
- Unless notifications aren't authorised — in which case those entries are marked suppressed, not delivered. A feed claiming to have notified you when the system dropped everything is a lie you'd eventually catch, and it would make the whole feed untrustworthy.
Delivered entries are then pushed one way to your account, so the feed survives a reinstall. The device is authoritative and the server is an archive — never a two-way merge. Rows are keyed per device, because two devices genuinely each plan and fire their own copies; the feed de-duplicates for display rather than pretending only one device existed.
8Background refresh
A background task tops the horizon up. It's registered at launch — synchronously, before anything else, which is the entire reason the app has a traditional app delegate at all.
Three honest limitations, and how the design absorbs each:
| Limitation | Compensation |
|---|---|
| Background refresh is opportunistic, and can be off system-wide or in Low Power Mode | With 14 days armed, a successful pass is needed roughly once a fortnight, not once a day. Foreground launches top it up too, and the anchors cover the case where nothing ever runs. |
| It gets about 30 seconds | Fetches run in parallel against a soft deadline; whatever returned in time is scheduled, and the previous horizon is kept for the rest. |
| A background launch isn't authenticated yet | The pass explicitly waits for the session to be restored first. |
That last row is not a nicety. Without it, every database query in a background pass returns zero rows — not an error, because row-level security filters rather than refuses. The engine would see an empty schedule, conclude there's nothing to remind you about, and confidently arm nothing at all. A security mechanism working exactly as designed, producing a silent failure two layers away.
9Privacy
Worth restating plainly, because "notifications" usually implies the opposite:
- There is no push server. No APNs, no notification payloads, nothing transmitted to deliver a reminder.
- Everything is computed from data already on your device, by code running on your device.
- Money amounts are redacted before storage when the Face ID lock is on — see redaction.
The notification log does mean event titles and birthday names sync as part of your account data. That's the same content the app already stores, not a new category of it — but it's a real consequence rather than a free lunch, which is why it's stated here and in the privacy policy rather than left implied.