Lifemaxxing AI is a habit app I build with a co-founder. It runs a 21 day program: you answer some onboarding questions, it assigns you a set of daily tasks, and it scores you on six RPG style attributes as you complete them. I have written before about how the scoring model broke. This one is smaller and, I think, more instructive, because the scoring model broke loudly and this broke quietly.
The app has one integer called _currentDayIndex. It runs 0 to 20. Almost every screen reads it. Without anyone deciding this, it became the app's clock. Then it quietly became the app's calendar too.
The design that felt obvious
You are building a 21 day program. Days are numbered. So you number them.
Completion history is a map from task ID to a list of booleans, one slot per day:
Map<String, List<bool>> completionHistory;
// completionHistory['pushups'][4] == true -> pushups done on day 5
Streaks walk that list backwards from today:
int dayToCheck = currentDay;
while (dayToCheck >= 0) {
if (isScheduledDay) {
if (dayToCheck < taskCompletions.length && taskCompletions[dayToCheck]) {
streak++;
} else {
break;
}
}
dayToCheck--;
}
This is clean. It is also a positional array, and positional arrays have a property I did not respect early enough: a wrong index is never an error. It is just a different answer that looks equally valid. No type complains, nothing throws, nothing logs. The checkmark lands on the wrong day and the UI renders it with total confidence.
So the correctness of the whole feature rests on one thing. currentDay had better be right.
Problem one: a derived value you can also write
Here is how the day gets computed on launch:
final daysSinceStart = now.difference(_programStartDate!).inDays;
_currentDayIndex = daysSinceStart.clamp(0, totalDays - 1);
await AppStorage.saveCurrentDay(_currentDayIndex);
That is a derivation. The day is a function of the start date and the current time. Fine so far.
But the same field has setters:
Future<void> advanceToNextDay() async { ... }
Future<void> goToPreviousDay() async { ... }
Future<void> jumpToDay(int dayIndex) async { ... }
All three write to storage. They exist for testing and for letting a user look back at earlier days, which are both legitimate needs. And the moment they exist, current_day_index in storage means two different things depending on how it got there. Sometimes it is a cache of a derivation. Sometimes it is an override that beat the derivation. Nothing in the stored value tells you which one you are holding.
This is the part I would go back and tell myself. If a value is derived, it should not have a setter. It should be a getter that recomputes, and anything that wants to look at a different day should be a separate viewingDayIndex that the UI reads and the domain logic never touches. Once you can write to a derived field you no longer have a derivation. You have a cache with no invalidation rule, which is a bug with a delay on it.
Problem two: two anchors for one question
There are two ways this codebase answers "what day is it".
// one
final daysSinceStart = now.difference(_programStartDate!).inDays;
// two
final onboardingDate = await AppStorage.getOnboardingCompleteDate();
final daysSinceOnboarding = now.difference(onboardingDate).inDays;
Both live in the same provider. Both are called. They agree only if the program start date and the onboarding completion date are the same instant, which they are not, because startProgram() guards on if (_programStartDate == null) and can be reached from more than one path.
Two functions answering the same question from two different anchors is not a bug you find by reading either function. Each is correct on its own terms. You only see it when you put them side by side, which nobody does, because they are in different sections of the file and they have different names.
Problem three: a day is not 24 hours
Duration.inDays truncates. It counts elapsed 24 hour periods, not calendar days.
A user finishes onboarding at 11:00pm. Midnight passes. It is a new date on their phone, the app still says Day 1, and it keeps saying Day 1 until 11:00pm the following night. Their first day is 25 hours long and their tasks reset at a time that has nothing to do with their day.
What makes this worth writing down rather than just fixing: the same file already knows better.
bool isNewDay() {
final lastActiveDay = DateTime(
_lastActiveDate!.year, _lastActiveDate!.month, _lastActiveDate!.day);
final todayDay = DateTime(now.year, now.month, now.day);
return todayDay.isAfter(lastActiveDay);
}
That one normalizes to midnight and compares calendar dates. It is right. It sits about 90 lines below the one that is not. Two definitions of "a day" in a single file, written by the same person, each looking reasonable in isolation.
Neither is timezone aware, either. DateTime.now() is local, and the stored ISO strings carry whatever offset the device had at write time. Fly east and you can lose a day. Fly west and you can gain one.
Problem four: the integer became a calendar
This is the one that made me stop and plan a rewrite.
Tasks can be scheduled on specific weekdays, so the streak calculator needs to know which weekday a given program day falls on. Here is how it finds out:
static int _getDayOfWeek(int dayIndex) {
// Day index starts from 0, and we assume day 0 is Monday
// So: 0=Monday(1), 1=Tuesday(2), ..., 6=Sunday(7)
return (dayIndex % 7) + 1;
}
Read the comment again. The weekday is derived from the program day index by modulo. There is no date anywhere in that calculation.
If a user starts on a Thursday and picks a Monday, Wednesday, Friday schedule, the app schedules those tasks on their Thursday, Saturday and Monday. Nothing warns anyone. The streak is then computed against the same fake calendar, so the result is internally consistent and externally wrong, which is the worst combination on offer. Internally consistent wrong answers do not get reported as bugs. They get reported as "the app feels off".
The integer did not stay a clock. It became a calendar, because once a value is the only thing in scope that knows anything about time, every piece of code that needs time will reach for it.
The storage layer will not save you
One more, because it compounds. All of this lives in SharedPreferences, a schemaless key value store. Every read is a parse, and every parse is a place where a type can quietly change under you.
There is real repair code in the storage service for a key that was written as a String and read back where a List<String> was expected:
if (rawValue is String) {
final decoded = jsonDecode(rawValue);
if (decoded is List) { ... }
// otherwise wrap the single string in a list
}
And completion booleans are serialized as strings, then parsed back by string comparison:
result[taskId] = completionStrings
.map((s) => s.toString().toLowerCase() == 'true')
.toList();
Anything that is not literally "true" becomes false. A corrupt entry does not throw. It becomes a missed day, which breaks a streak, which lowers an attribute score. The failure travels three layers before a human sees it, and by then it presents as a scoring bug, so that is where you go looking.
Local storage is a database. It has no schema, no constraints and no migrations, and you own all three. Writing a version number next to the data on day one costs nothing. Adding one after you have shipped to users costs a migration you have to guess your way through.
What I would build instead
The fix is not clever. It is mostly refusing to store anything I can compute.
Store events, not slots. A completion becomes a record of {taskId, completedOnLocalDate, utcTimestamp, tzOffset}, appended, never indexed positionally. Nothing can land in the wrong slot if there are no slots.
One anchor, written once. A single programStartLocalDate normalized to midnight local. Every other time question is a pure function of it.
Day boundaries on calendar dates, never on Duration.inDays. Normalize both ends to midnight, then subtract.
Weekdays come from real dates. startDate.add(Duration(days: i)).weekday, not (i % 7) + 1.
currentDay is a getter with no setter. Browsing history is a separate viewingDay that only the UI reads.
The general version, and the reason I am bothering to write it down: the bug was never inside any one function. Every function quoted here is defensible on its own. The bug was that a derived value got a permanent home in storage, and then everything else in the app started treating that home as the truth. Derived state stops being derived the moment you can write to it, and the code reading it has no way to tell the difference.
If you are building on any kind of program timeline, a streak, a cohort, a trial period, a drip sequence, that is the question worth asking on day one. What is the single anchor, and is every other time value in this app a pure function of it?
Lifemaxxing AI lives at lifemaxxingai.com, if you want to see what all of this was in service of.
Top comments (0)