Dawnlet · under the hood

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.

27 kinds 14-day horizon 64 pending slots No push server

§The problem

The previous implementation sent two reminders. On every launch it cancelled them and scheduled the next single occurrence of each.

The defect that forced a rewrite

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:

1
Plan
Nine per-module planners each propose what should fire in the next fortnight, running in parallel. Each is allowed to fail independently — a problem reading your budgets can't take down your event reminders.
2
Filter
Drop kinds you've turned off, and kinds requiring Plus if Plus isn't active — checked here, not just on the settings screen, so a lapsed purchase degrades correctly on its own.
3
Shape
Apply quiet hours and per-kind daily caps centrally.
4
Budget
Fit what's left into 64 slots by tier and priority.
5
Diff
Compare against what's already scheduled and change only what differs.

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:

TierHoldsSlots
A — anchorsThree repeating daily/weekly notifications with generic copy3
B — reserveHeld back for event-driven kinds armed between planning passes8
C — datedHigh-priority dated items: event reminders, birthdays, bills due, todos due38
D — deferredDigests, streak-at-risk, overdue nudges, planning prompts15

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.

A packed calendar shortens the horizon, and that's correct

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.

Why they're worth a tier of their own

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:

KindArmed by
Timer still runningStarting or resuming a timer
Timer paused too longPausing one
Streak milestoneChecking off the habit that hits the milestone
Inactivity nudgeRe-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:

Digests & nudges
Shift
Moved forward to the end of quiet hours. A morning digest is just as useful at 07:00 as at 06:00.
Time-critical
Drop
An event reminder six hours late is worse than none — it's actively misleading about what's about to happen.

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.

Nothing can record a delivery at the moment it happens

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:

  1. 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.
  2. 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.
  3. 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:

LimitationCompensation
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.
The subtlest bug in the system

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.