DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

The Reminder Queue Refills Only When You Open the App

The bug report was one line from someone testing Lifemaxxing AI: "why did it tell me I got nothing done today, I did everything."

He was right, and the notification was working exactly as written.

Lifemaxxing AI is a habit app. You answer some onboarding questions, it assigns you tasks, and then it nags you. The nagging is the product. If the reminders are wrong, the app is a to-do list with a leaderboard bolted on.

So I went looking for why a user who completed every task still got the 7pm guilt trip. I found something worse than a bad conditional. I found that the whole reminder system depends on the user already having come back.

Two notifications is the entire queue

Here is the scheduler, trimmed:

Future<void> scheduleNotifications(
    Map<String, List<bool>> completionHistory,
    [UserTaskProvider? taskProvider]) async {
  await init();
  await cancelAllNotifications();

  await _scheduleMorningNotification(taskProvider);

  final bool tasksCompleted = await _wereTasksCompletedToday(completionHistory);
  if (!tasksCompleted) {
    await _scheduleAfternoonNotification(taskProvider);
  }
}
Enter fullscreen mode Exit fullscreen mode

Cancel everything. Schedule one morning notification for tomorrow. Schedule one afternoon notification for today, or tomorrow if that time has already passed. Stop.

Both go out through zonedSchedule with no matchDateTimeComponents, so neither one repeats. They are two one-shot appointments, not a recurring rule.

Which means the pending queue is at most two items deep, and the only thing that ever refills it is scheduleNotifications running again. Every call site needs the app to be alive: cold start in main.dart, the main screen's init, the task event bus when a task is completed, and the permission grant right after onboarding.

So a user opens the app on Monday, gets Tuesday morning's reminder scheduled, ignores it, and by Tuesday evening there is nothing pending at all. The app goes quiet at exactly the moment the user is drifting, which is the only moment the reminder had a job to do.

I did not sit down and design a retention feature that switches itself off when retention drops. I built it one notification at a time and never stood back to ask what the queue looks like for someone who is not there.

The gate that never closes

Back to the original complaint. Here is the check:

Future<bool> _wereTasksCompletedToday(
    Map<String, List<bool>> completionHistory) async {
  final DateTime now = DateTime.now();
  final int daysSinceEpoch = now.difference(DateTime(1970, 1, 1)).inDays;

  for (final List<bool> taskHistory in completionHistory.values) {
    if (taskHistory.length > daysSinceEpoch && taskHistory[daysSinceEpoch]) {
      return true;
    }
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

completionHistory maps a task id to a list of booleans, one slot per program day. The lists are created with List.filled(_totalDays, false), and _totalDays is 21. Everywhere else in the app, that list is read at _currentDayIndex, a number between 0 and 20.

This function indexes it by days since the Unix epoch. Today that number is 20710. The guard taskHistory.length > daysSinceEpoch is asking whether 21 is greater than 20710.

It never is. The function always returns false. The afternoon notification always schedules, no matter what you did. The config file sitting right next to it even documents the intent the code fails to deliver: "Only sends if you haven't completed any tasks for the day."

The arithmetic is not the part that bothers me. The part that bothers me is that the notification layer needed to know what day it was and, rather than asking the part of the app that owns that question, it invented its own calendar from scratch. Nothing crashed. Nothing logged. The guard reads as perfectly reasonable in a diff.

Zero tasks await you

The message content has the same shape of problem. The config holds 26 morning lines and 34 afternoon lines, and the morning ones carry a placeholder:

{ "title": "Rise & Grind Your X Tasks",   "subtitle": "Start your morning ritual" },
{ "title": "X Tasks, 1 You",              "subtitle": "Let's see what you're made of" },
{ "title": "You Scheduled X Wins Today",  "subtitle": "No time like right now" }
Enter fullscreen mode Exit fullscreen mode

Filled in like this:

final int taskCount = taskProvider?.getTodayTasks().length ?? 0;
String title = notification['title'] ?? 'Rise & Grind';
title = title.replaceAll(' X ', ' $taskCount ');
Enter fullscreen mode Exit fullscreen mode

Two things go wrong here. The provider is optional, and two of the four call paths pass nothing at all, including the one that runs immediately after onboarding when the user grants permission. taskProvider?.getTodayTasks().length ?? 0 then quietly resolves to zero, so the first notification a brand new user ever receives can read "Rise & Grind Your 0 Tasks". A fallback that produces a plausible wrong answer is worse than one that produces no answer.

The second thing: even when the provider is passed, getTodayTasks() returns today's list, and the morning notification is scheduled for tomorrow. The count is frozen at schedule time and read by a human twelve hours later.

Two functions that sound like the same question

Future<bool> areNotificationsEnabled() async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getBool(_notificationsEnabledKey) ?? false;
}

Future<bool> areSystemNotificationsEnabled() async {
  return await _notificationService.areNotificationsEnabled();
}
Enter fullscreen mode Exit fullscreen mode

The first reads a boolean the app wrote about itself. The second actually asks iOS. They agree right up until the user revokes permission in system settings, and from then on the app believes notifications are on forever, because nothing ever rewrites that flag.

It gets better. NotificationPermissionHelper.checkPermissionStatus() sounds like the OS call and is in fact the preference read, and the main screen uses it to decide whether to reschedule. The settings screen is the only place in the app that asks the OS directly.

Two things I would have called harmless

Every scheduled notification gets a random ID that is written to SharedPreferences and never read back. Cancellation goes through cancelAll(), so those stored IDs are pure ceremony. Dead state that looks like a design.

And the config loader has defaults that cannot fire:

return _config['configuration']['morningTimeRange'] ?? { /* sensible defaults */ };
Enter fullscreen mode Exit fullscreen mode

If the JSON failed to load, _config is an empty map, _config['configuration'] is null, and the lookup on null throws before the ?? is ever reached. The defensive default is unreachable in precisely the case it was written for. The accessor one function below it uses _config['morningNotifications'] ?? [], a top level key, and is genuinely safe. Same file, same author, same afternoon.

What I am changing

A repeating daily rule instead of hand-rolled appointments, so the queue survives a user who stops opening the app. One owner of "what day is it" that every subsystem has to call. Placeholders that fail loudly when the data is missing instead of resolving to zero. System permission treated as the only source of truth, with the local flag reduced to a cache that gets refreshed on every foreground.

But the lesson I actually want to keep is about the category, not the fixes. Nothing here crashed. Nothing here logged. Every piece of it produced output that looked right on a screen. A reminder system does not fail by throwing, it fails by being plausible, and plausible is not something you catch by reading the code. You catch it by leaving the app closed for three days and checking what is still pending.

Top comments (5)

Collapse
 
devshakib profile image
K M Shahriar Hossain •

The repeating rule fixes the empty queue, but it can't carry your other requirement. A matchDateTimeComponents notification is fixed when you schedule it, so it can't know whether today's tasks got done, and the 7pm one fires either way. That's the original bug report again.

A rolling window gets you both: schedule the next 7 or 14 afternoons as one-shots with ids derived from the date (say 20260918), and when the day's tasks are completed, cancel only that day's id. The queue survives a week of absence, and finishing your tasks still silences the evening. It also gives those stored ids a job, since a deterministic id is one you can cancel.

Two limits shape the window. iOS keeps only the 64 soonest pending local notifications per app, so two a day for 14 days fits. And Android drops scheduled alarms on reboot unless the boot receiver is set up; flutter_local_notifications documents the RECEIVE_BOOT_COMPLETED permission and receiver for exactly this.

Collapse
 
nabeelbaghoor profile image
Nabeel Hassan •

You are right, and I had those two requirements pulling against each other without noticing. A repeating rule fixes the empty queue by giving up the completion gate, which is the exact bug the post opens with.

The rolling window is the better answer, and it also repairs the thing I filed under dead state. Today the ids are _random.nextInt(100000) for morning and the same plus 100000 for afternoon, written to SharedPreferences and never read again, because cancellation goes through cancelAll(). A date derived id is the reason to keep an id at all.

Two things in the repo that your plan runs into. The schedule time is randomized inside a configured range on every call, so pre-scheduling 14 afternoons freezes 14 random times up front instead of rolling a fresh one each day. Workable, but the window has to be re-rolled when it is topped up rather than just extended. And the morning copy interpolates today's task count into the title, which is already stale twelve hours out and absurd thirteen days out, so a window forces me to drop the count from pre-scheduled text entirely. That is probably the right call anyway.

On the two limits: two a day for 14 days sits comfortably under 64. The boot receiver does not apply yet, since there is no android directory in the project, iOS and web only. Noting it for whenever that changes.

Collapse
 
devshakib profile image
K M Shahriar Hossain •

The randomised time doesn't have to block the window: derive it from the date instead of rolling it. Seed a small PRNG with the day number plus a per-install salt, and take the minute from that. Every window, every top-up and every re-schedule then computes the same time for a given day, so the queue is idempotent — you can cancel and rebuild it whenever you like without a reminder appearing to move.

On the count: dropping it from pre-scheduled copy is the right call, and you can still have it where it's true. Keep the window generic, and when the app is opened, replace just today's remaining notification with count-bearing text. That's one cancel and reschedule of a single id, and the count is minutes old instead of days.

Collapse
 
nabeelbaghoor profile image
Nabeel Hassan •

This is the piece I was missing, thank you. I had framed randomness and a pre-built window as a tradeoff, and deriving the minute from the date just removes the tradeoff. Rebuild the window as often as you like and nothing moves.

Two things I would add after rereading the service. First, the time is not the only random roll: the message itself comes from a random pick out of the JSON config, and the ids come from nextInt(100000). So the same seed has to drive the copy choice as well, or a rebuild keeps the time and swaps the text, and the ids become date-derived like you suggested in the first comment. Second, since the app also has a web target, I would hash (day number, install salt) myself rather than lean on Random(seed), so the value never depends on which runtime's PRNG is doing the work. The salt can sit in SharedPreferences next to the ids it already stores.

The count trick is exactly right too. Generic copy in the window, and one cancel plus reschedule of today's id on open, so the only number a user ever sees was computed minutes ago. Going to build it that way.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.