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);
}
}
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;
}
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" }
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.
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();
}
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 */ };
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 (0)