cd /news/ai-products/the-reminder-queue-refills-only-when… · home topics ai-products article
[ARTICLE · art-128566] src=dev.to ↗ pub= topic=ai-products verified=true sentiment=↓ negative

The Reminder Queue Refills Only When You Open the App

A developer investigating a bug report in the habit app Lifemaxxing AI found that its reminder scheduler only refills its notification queue when the app is opened, meaning the app goes silent precisely when users are drifting away. The developer also traced a separate bug in which a day-indexing check compared a 21-day task history against days since the Unix epoch (currently over 20,000), causing the completion check to always return false and the afternoon guilt-trip notification to fire regardless of user activity.

by read6 min views1 publishedSep 13, 2026

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.

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);
  }
}

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.

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;
}

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.

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" }

Filled in like this:

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

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.

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

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

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.

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 has defaults that cannot fire:

return _config['configuration']['morningTimeRange'] ?? { /* sensible defaults */ };

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.

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.

── more in #ai-products 4 stories · sorted by recency
── more on @lifemaxxing ai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-reminder-queue-r…] indexed:0 read:6min 2026-09-13 ·