Skip to content

fix: refresh habits screen state when returning after midnight - #31

Merged
Markel15 merged 1 commit into
masterfrom
refactor-grid-notes
Oct 22, 2026
Merged

Markel15 merged 1 commit into
masterfrom
refactor-grid-notes

Conversation

@Markel15

Copy link
Copy Markdown
Owner

Symptom

Opening the Habits tab before midnight (e.g. at 23:30), leaving the app in the background without killing the process, and returning after midnight (e.g. at 00:20) left the screen in a stale / hybrid state:

  • Header progress (completedToday / totalHabits) and card completion states still reflected the previous day.
  • In NumericHabitCard, the selection dot and value counter stayed anchored on yesterday even if the card recomposed, and tapping +/- could apply yesterday's base todayValue to the new date.
  • In HabitCard (boolean), the new day could remain treated as a future (disabled) cell in the weekly calendar or fail to scroll across a Sunday -> Monday rollover.

Root cause

There was no reactive source of truth for "today" across the Habits feature; LocalDate.now() was called independently in multiple places:

  1. HabitViewModel.init called getHabitsWithStatus() only once. Its default parameter (date: LocalDate = LocalDate.now()) was evaluated at ViewModel creation time and stayed captured inside the UseCase flow for the entire lifetime of the ViewModel. Returning from the background without a DB emission never re-ran the combine block, and even on a DB emission the UseCase still computed isCompletedToday, todayValue and streaks against the captured date from the previous day.
  2. NumericHabitCard keyed weekStart and selectedDate only on remember(weekOffset). Because weekOffset stayed 0 across midnight, selectedDate never reset to the new day.
  3. HabitScreen passed LocalDate.now() directly to incrementNumericHabit / decrementNumericHabit while reading currentValue = habitWithStatus.todayValue (computed for the stale date).
  4. HabitCard read LocalDate.now() directly during composition and only seeded rememberWeekCalendarState's firstVisibleWeekDate on initial composition.

Fix

Make today: LocalDate part of HabitUiState.Success and drive it from a lifecycle-aware StateFlow in HabitViewModel:

  • HabitUiState.kt: add today: LocalDate to HabitUiState.Success.
  • HabitViewModel.kt: implement DefaultLifecycleObserver and hold a _today = MutableStateFlow(LocalDate.now()) updated in onResume via refreshTodayIfNeeded(). Switch the UseCase subscription with _today.flatMapLatest { today -> getHabitsWithStatus(today).map { today to it } } so today and habits always emit together without an intermediate frame where the date has advanced but the statuses have not.
  • HabitScreen.kt: register the ViewModel as a lifecycle observer via DisposableEffect and pass state.today down to HabitCard, NumericHabitCard and the today increment/decrement callbacks.
  • NumericHabitCard.kt: accept today as a parameter and include it as a key in remember(today) / remember(today, weekOffset) for weekOffset, weekStart and selectedDate.
  • HabitCard.kt: accept today as a parameter and scroll the WeekCalendar to today inside a LaunchedEffect(today) when the date advances.
  • HabitViewModelTest.kt: add unit test covering the midnight rollover via refreshTodayIfNeeded().

Behavior note

Because _today is a MutableStateFlow, resuming the app on the same calendar day is a no-op (structural equality check prevents any emission or DB re-computation). Re-computation only happens when the date has actually changed while the app was in the background.

Symptom
-------
Opening the Habits tab before midnight (e.g. at 23:30), leaving the app in the
background without killing the process, and returning after midnight (e.g. at
00:20) left the screen in a stale / hybrid state:

  * Header progress (completedToday / totalHabits) and card completion states
    still reflected the previous day.
  * In NumericHabitCard, the selection dot and value counter stayed anchored on
    yesterday even if the card recomposed, and tapping +/- could apply yesterday's
    base todayValue to the new date.
  * In HabitCard (boolean), the new day could remain treated as a future
    (disabled) cell in the weekly calendar or fail to scroll across a Sunday ->
    Monday rollover.

Root cause
----------
There was no reactive source of truth for "today" across the Habits feature;
LocalDate.now() was called independently in multiple places:

  1. HabitViewModel.init called getHabitsWithStatus() only once. Its default
     parameter (date: LocalDate = LocalDate.now()) was evaluated at ViewModel
     creation time and stayed captured inside the UseCase flow for the entire
     lifetime of the ViewModel. Returning from the background without a DB
     emission never re-ran the combine block, and even on a DB emission the
     UseCase still computed isCompletedToday, todayValue and streaks against the
     captured date from the previous day.
  2. NumericHabitCard keyed weekStart and selectedDate only on
     remember(weekOffset). Because weekOffset stayed 0 across midnight,
     selectedDate never reset to the new day.
  3. HabitScreen passed LocalDate.now() directly to incrementNumericHabit /
     decrementNumericHabit while reading currentValue = habitWithStatus.todayValue
     (computed for the stale date).
  4. HabitCard read LocalDate.now() directly during composition and only seeded
     rememberWeekCalendarState's firstVisibleWeekDate on initial composition.

Fix
---
Make `today: LocalDate` part of HabitUiState.Success and drive it from a
lifecycle-aware StateFlow in HabitViewModel:

  * HabitUiState.kt: add `today: LocalDate` to HabitUiState.Success.
  * HabitViewModel.kt: implement DefaultLifecycleObserver and hold a
    `_today = MutableStateFlow(LocalDate.now())` updated in onResume via
    refreshTodayIfNeeded(). Switch the UseCase subscription with
    `_today.flatMapLatest { today -> getHabitsWithStatus(today).map { today to it } }`
    so `today` and `habits` always emit together without an intermediate frame
    where the date has advanced but the statuses have not.
  * HabitScreen.kt: register the ViewModel as a lifecycle observer via
    DisposableEffect and pass `state.today` down to HabitCard, NumericHabitCard
    and the today increment/decrement callbacks.
  * NumericHabitCard.kt: accept `today` as a parameter and include it as a key in
    `remember(today)` / `remember(today, weekOffset)` for weekOffset, weekStart
    and selectedDate.
  * HabitCard.kt: accept `today` as a parameter and scroll the WeekCalendar to
    `today` inside a `LaunchedEffect(today)` when the date advances.
  * HabitViewModelTest.kt: add unit test covering the midnight rollover via
    refreshTodayIfNeeded().

Behavior note
-------------
Because `_today` is a MutableStateFlow, resuming the app on the same calendar
day is a no-op (structural equality check prevents any emission or DB
re-computation). Re-computation only happens when the date has actually changed
while the app was in the background.
@Markel15
Markel15 merged commit 04b850c into master Oct 22, 2026
1 check passed
@Markel15
Markel15 deleted the refactor-grid-notes branch October 22, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant