Skip to content

steps reset at midnight, and a dot for whether anything is syncing - #216

Merged
abdulsaheel merged 2 commits into
mainfrom
fix/steps-midnight-and-sync-dot
Aug 8, 2026
Merged

steps reset at midnight, and a dot for whether anything is syncing#216
abdulsaheel merged 2 commits into
mainfrom
fix/steps-midnight-and-sync-dot

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

User description

Two things from TestFlight feedback.

Steps never reset at midnight. The Today tile shows the derived day total plus the live count, and the live count is "since the BLE connection began". This app is built around never dropping that connection, so it spans midnight — at 00:01 the tile carried the whole of yesterday on top of today's zero and kept climbing from there. Reported as steps and calories not resetting, just accumulating.

The rebase happens at the day boundary, and the boundary is watched from the sample path rather than from the widget that displays it: a phone parked on the Sleep tab across midnight would otherwise make its first read of the new day the window's first read of any day, and a first read has to count in full — rebasing there instead throws away a real walk, which the live-coverage tests catch.

I could not reproduce the calories half. That tile reads the derived day value with no live component and the day rolls over on the screen's own refresh, so I think the climbing steps number is what was being described. Worth a second look if it persists.

No way to tell whether a sync is happening. Sync is deliberately invisible here — no spinners, no progress, no copy — and that is still right, because it runs constantly and there is nothing to act on. But invisible and broken look identical, which is what the report is actually about. So: a 6pt dot beside the wordmark that breathes while records are landing and is absent otherwise. No text, nothing to dismiss, and the space is held either way so the title never shifts. It is driven by "records are landing right now", not by "connected" or "a sync was requested", so a quiet dot means a quiet link rather than a broken one.


PR Type

Bug fix, Enhancement, Tests


Description

  • Steps tile now resets at local midnight instead of accumulating across days

  • Animated 6pt sync dot beside wordmark shows when band data is actively landing

  • LiveStepDayWindow class isolates midnight-rebase logic; 5 unit tests added

  • SyncDot widget stops animation when inactive; 4 widget tests added


Diagram Walkthrough

flowchart LR
  A["BLE session counter\n(connection-lifetime)"]
  B["LiveStepDayWindow\n(ble_state.dart)"]
  C["AppState.liveSteps\n(app_state.dart)"]
  D["Today tile\n(today_screen.dart)"]
  E["AppState.syncingNow\n(_lastIngestMs)"]
  F["SyncDot widget\n(sync_dot.dart)"]
  G["Title Row\n(today_screen.dart)"]

  A -- "sessionTotal + today label" --> B
  B -- "rebased today-only count" --> C
  C -- "display value" --> D
  E -- "active bool" --> F
  F -- "breathes while landing" --> G
Loading

File Walkthrough

Relevant files
Bug fix
2 files
ble_state.dart
Add LiveStepDayWindow class for midnight step rebase         
+52/-0   
app_state.dart
Wire LiveStepDayWindow into liveSteps; add syncingNow getter
+43/-2   
Enhancement
2 files
sync_dot.dart
New animated SyncDot widget for sync activity indication 
+96/-0   
today_screen.dart
Add SyncDot to title row; include syncingNow in select     
+22/-9   
Configuration changes
1 files
design.dart
Export new sync_dot.dart from design barrel                           
+1/-0     
Tests
2 files
live_step_day_window_test.dart
Unit tests for midnight rebase and reconnect edge cases   
+68/-0   
sync_dot_test.dart
Widget tests for SyncDot visibility and animation lifecycle
+61/-0   

…ening

the today tile shows the derived day total plus the live count, and the live
count is since the ble connection began. the whole engine is built around never
dropping that connection, so it spans midnight — at 00:01 the tile carried the
whole of yesterday on top of today and kept climbing.

the day boundary is watched from the sample path rather than from the widget: a
phone parked on another tab across midnight would otherwise make its first read
of the day the first read of any day, and a first read counts in full.

sync stays invisible — no spinners, no copy — but invisible and broken look the
same, so a 6pt dot next to the wordmark breathes while records are actually
landing and is absent otherwise.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 60b74840-fd3e-4839-aa57-6b2d221d7cd7

📥 Commits

Reviewing files that changed from the base of the PR and between 8631555 and 2d10497.

📒 Files selected for processing (6)
  • lib/ble/ble_state.dart
  • lib/state/app_state.dart
  • lib/ui/design/sync_dot.dart
  • test/live_step_day_window_test.dart
  • test/sync_activity_window_test.dart
  • test/sync_dot_test.dart
📝 Walkthrough

Walkthrough

Changes

Daily steps and synchronization

Layer / File(s) Summary
Local-day live step accounting
lib/ble/ble_state.dart, lib/state/app_state.dart, test/live_step_day_window_test.dart
LiveStepDayWindow rebases connection-lifetime counts by local day, handles reconnect resets, clamps negative totals, and is updated during live sample ingestion.
Synchronization activity state
lib/state/app_state.dart
Historical record ingestion records its arrival time and exposes syncingNow for the six-second active window.
Today synchronization indicator
lib/ui/design/design.dart, lib/ui/design/sync_dot.dart, lib/ui/today/today_screen.dart, test/sync_dot_test.dart
Today observes syncingNow and displays the animated, accessible SyncDot while preserving its layout space when inactive.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HistoricalSync
  participant AppState
  participant TodayScreen
  participant SyncDot
  HistoricalSync->>AppState: ingest landed records
  AppState->>AppState: record arrival time
  TodayScreen->>AppState: read syncingNow
  AppState-->>TodayScreen: rebuild on sync state change
  TodayScreen->>SyncDot: set active state
Loading

Possibly related PRs

Suggested labels: Review effort 3/5

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both main changes: resetting steps at midnight and adding a syncing indicator.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 2d10497)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Stale liveSteps on day change

In liveSteps, _liveStepDay.stepsToday(_rawSessionSteps, today) is called to get raw, and then dayChanged is checked against _liveStepDay.day BEFORE that call mutates _day. However, the cushion reset (_sessionStepsCushion = 0) happens after raw is already computed with the rebased value. The real hazard is that _liveStepDay.stepsToday is also called from the sample path (_onSampleTick) with committedThisTick as a guard — meaning if no minute has completed yet after midnight, the getter call in liveSteps is the first to observe the new day and will correctly rebase. But the dayChanged flag is evaluated before stepsToday updates _day, so on the very first call after midnight _liveStepDay.day still holds yesterday's label, dayChanged is true, the cushion is cleared, and raw is 0. This is actually correct behavior, but the ordering is fragile: if _liveStepDay.day were ever updated by a path other than stepsToday, the guard could misfire. More concretely: dayChanged reads _liveStepDay.day before stepsToday runs, so it reflects the PRE-call state — this is intentional but undocumented and easy to break in a follow-up.

int get liveSteps {
  final today = todayLabel();
  final dayChanged = _liveStepDay.day != null && _liveStepDay.day != today;
  final raw = _liveStepDay.stepsToday(_rawSessionSteps, today);
  // The cushion holds a PRE-midnight session total for a few seconds so the
  // tile doesn't visibly dip on a reconnect. Carried across the boundary it
  // would re-introduce exactly the number we just rebased away.
  if (dayChanged) _sessionStepsCushion = 0;
  if (_sessionStepsCushion <= 0) return raw;
  if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >=
      _sessionCushionGraceMs) {
    _sessionStepsCushion = 0;
    return raw;
  }
syncingNow not reset on dispose failure path

_syncQuietTimer is cancelled in dispose, which is correct. However, _markSyncActivity creates a new Timer that calls notifyListeners() in its callback. If dispose is called between _markSyncActivity setting the timer and the timer firing, the cancel in dispose handles it. But _markSyncActivity is called from _onDataStored, which is an async callback that can fire after dispose if the durable-write completes after the widget tree tears down. In that case _syncQuietTimer is set on a disposed object, and the timer's callback calls notifyListeners() which is guarded by _disposed — so no crash, but the timer leaks until it fires. This is a minor resource leak rather than a correctness bug, but it follows the §4.3 pattern of a flag/timer set without a try/finally or disposed-check at the set site.

void _markSyncActivity() {
  final now = DateTime.now().millisecondsSinceEpoch;
  _syncActivity.mark(now);
  _syncQuietTimer?.cancel();
  _syncQuietTimer = Timer(
    Duration(milliseconds: _syncActivity.windowMs),
    () {
      _syncQuietTimer = null;
      notifyListeners();
    },
  );
}

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 2d10497
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Remove duplicate selector causing double subscription

syncingNow is already included in the outer context.select tuple that drives the
screen rebuild, so this inner context.select call registers a second selector on the
same widget's BuildContext. This is redundant and causes the titleWidget subtree to
subscribe separately, but more importantly titleWidget is built inside the build
method after the outer select has already captured syncingNow — the value is
available on app (via context.read()) without a second selector. Use app.syncingNow
instead to avoid the duplicate subscription.

lib/ui/today/today_screen.dart [224]

-SyncDot(active: context.select<AppState, bool>((a) => a.syncingNow)),
+SyncDot(active: app.syncingNow),
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that syncingNow is already captured in the outer context.select tuple, making the inner context.select on line 224 a redundant subscription. Using app.syncingNow instead avoids the duplicate listener registration, which is a valid minor optimization.

Low
Document first-observation assumption to prevent fabricated step counts

When a day boundary is crossed and sessionTotal is negative (clamped to 0 by the
earlier guard), _base is set to 0, which is correct. However, the negative-input
clamp happens before the day-change branch, so _base = sessionTotal on a day change
will always receive the already-clamped value — this is fine. But if rawSessionTotal
is negative on a day change, the base is set to 0 rather than the raw value, which
means the reconnect-reset guard (if (sessionTotal < _base)) can never fire on that
same call. This is actually correct behavior, but the test 'a negative reading on a
day change is not a baseline either' passes _base = 0 after clamping, then expects
stepsToday(90, '2026-08-09') to return 90 — which works. The real defect is that on
a day change, _base is set to the clamped sessionTotal (0 for negative inputs), but
the reconnect-reset guard runs after the day-change branch and could lower _base
further — which is harmless here. No actual bug exists in this path. However, there
is a fabrication risk: on the very first observation (_day == null), _base is set to
0 and the full sessionTotal is returned as today's steps. If the session started
yesterday and the first call happens to be on a new day (e.g., the sample path
missed midnight), the entire session total is attributed to today. The sample path
in app_state.dart is supposed to prevent this, but LiveStepDayWindow itself has no
defense. Consider storing the day of first observation and returning 0 if the first
call's today differs from what the session counter implies — but since the codebase
relies on the sample path guarantee, at minimum document this assumption explicitly
in the class contract so future callers are not surprised.

lib/ble/ble_state.dart [604-628]

 int stepsToday(int rawSessionTotal, String today) {
     final sessionTotal = rawSessionTotal > 0 ? rawSessionTotal : 0;
     if (_day == null) {
+      // First observation. The session counter is per-connection and per-
+      // process, so whatever it holds now was walked during this session — on
+      // this day. This is only correct if the sample path has been keeping
+      // _liveStepDay current across midnight; callers must not let the first
+      // call be the first post-midnight read or the whole prior session is
+      // attributed to today.
       _day = today;
       _base = 0;
     } else if (_day != today) {
       _day = today;
       _base = sessionTotal;
     }
     if (sessionTotal < _base) _base = sessionTotal;
     final n = sessionTotal - _base;
     return n > 0 ? n : 0;
   }
Suggestion importance[1-10]: 2

__

Why: The suggestion only proposes adding a comment to document an existing assumption — no actual code logic changes. The improved_code is identical to existing_code except for an expanded comment block, which falls into the "adding comments" category that should score low.

Low

Previous suggestions

Suggestions up to commit 8631555
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix day-boundary base when counter resets across midnight

When a day boundary is crossed (_day != today), _base is set to sessionTotal at that
moment. However, if sessionTotal has already decreased due to a reconnect before the
day boundary is processed (i.e., sessionTotal < _base from the previous day), the
reconnect-reset guard below runs after the boundary update and correctly adjusts
_base. But if the day boundary and a reconnect happen simultaneously (first
observation on a new day with a reset counter at 0), _base is set to 0 which is
correct. The real issue is the skipped-day case: if the window was not updated for
multiple days (e.g., phone off), _day != today still fires and sets _base =
sessionTotal, which correctly gives 0 for the new day. This logic is sound, but the
_base = sessionTotal on day rollover can be wrong if sessionTotal already decreased
due to a reconnect on the new day before the window saw the old day. The reconnect
guard (if (sessionTotal < _base) _base = sessionTotal) only runs after the boundary
assignment, so if sessionTotal is 0 on a new day after reconnect, _base is set to 0
(correct). However, if the day rolls over and sessionTotal is, say, 50 (new session
already walked 50 steps on the new day), _base is set to 50, discarding those 50
steps. The fix is to set _base to sessionTotal only when the counter has not reset,
i.e., only when sessionTotal >= _base; otherwise set _base = 0.

lib/ble/ble_state.dart [578-590]

 if (_day == null) {
-  // First observation. The session counter is per-connection and per-
-  // process, so whatever it holds now was walked during this session — on
-  // this day. Rebasing here instead would DISCARD a real walk, which is
-  // what the live-coverage tests caught.
   _day = today;
   _base = 0;
 } else if (_day != today) {
-  // A day boundary crossed while this window was watching: everything the
-  // counter holds belongs to the day that just ended.
   _day = today;
-  _base = sessionTotal;
+  // If the counter reset (reconnect) across the boundary, the new session
+  // started fresh — base at 0 so steps already walked today are counted.
+  // If the counter is still climbing, everything up to this point belonged
+  // to the day that just ended.
+  _base = sessionTotal >= _base ? sessionTotal : 0;
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion identifies a real edge case where a reconnect happens on a new day before the window processes the boundary, potentially discarding steps already walked. However, the existing reconnect guard (if (sessionTotal < _base) _base = sessionTotal) runs immediately after and would handle the case where sessionTotal is 0. The scenario where sessionTotal is 50 on a new day after reconnect setting _base = 50 and discarding those steps is a valid concern, though it's a narrow edge case. The fix is logically sound but the impact may be limited.

Low
General
Remove redundant nested select causing double rebuild

syncingNow is already included in the outer context.select tuple that drives the
rebuild of this widget subtree. Adding a second context.select call for the same
value inside the titleWidget creates a redundant subscription on the same
BuildContext, causing an extra rebuild path for every notifyListeners() call. Pass
the already-selected value down instead of re-selecting it.

lib/ui/today/today_screen.dart [224]

-SyncDot(active: context.select<AppState, bool>((a) => a.syncingNow)),
+SyncDot(active: app.syncingNow),
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that syncingNow is already included in the outer context.select tuple, making the inner context.select redundant. However, app is obtained via context.read<AppState>() which doesn't subscribe to changes, so using app.syncingNow would not trigger rebuilds for SyncDot independently. The outer select already handles rebuilds for the whole widget, so the redundancy is real but the fix using app.syncingNow is correct in this context since the outer select already covers it.

Low

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_state.dart`:
- Around line 591-596: Normalize negative session totals to zero in the
session-total handling near the rebasing logic in lib/ble/ble_state.dart:591-596
before updating _base and calculating n, while preserving existing
positive-total behavior. Add or update the test at
test/live_step_day_window_test.dart:55-59 to assert that a zero reading
following -5 returns zero.

In `@lib/state/app_state.dart`:
- Around line 3590-3610: Add a one-shot expiry Timer alongside _lastIngestMs,
re-arm it whenever records are recorded so it fires after _syncActiveWindowMs,
and call notifyListeners from its callback to refresh syncingNow. Cancel and
clear the Timer in dispose() to prevent callbacks after disposal.
- Around line 2757-2760: Move the durable-write arrival timestamp update into
_onDataStored(), ensuring _lastIngestMs is set whenever the write completes even
if _lastRecTs was already advanced. Remove the completed _runSyncBurst-based
arrival inference, preserve listener notification behavior, and add a regression
test covering _onDataStored() advancing _lastRecTs before the sync callback.

In `@test/sync_dot_test.dart`:
- Around line 54-59: Add a unique key to the outer fixed SizedBox rendered by
SyncDot, then update both getSize calls in the “it occupies the same space
either way” test to use find.byKey with that key instead of
find.byType(SyncDot).
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e1e12315-556b-4161-a905-cb56f9fdd9f7

📥 Commits

Reviewing files that changed from the base of the PR and between baba508 and 8631555.

📒 Files selected for processing (7)
  • lib/ble/ble_state.dart
  • lib/state/app_state.dart
  • lib/ui/design/design.dart
  • lib/ui/design/sync_dot.dart
  • lib/ui/today/today_screen.dart
  • test/live_step_day_window_test.dart
  • test/sync_dot_test.dart

Comment thread lib/ble/ble_state.dart
Comment thread lib/state/app_state.dart
Comment on lines 2757 to 2760
if (frontierAfter != null && frontierAfter > (_lastRecTs ?? 0)) {
_lastRecTs = frontierAfter;
_lastIngestMs = DateTime.now().millisecondsSinceEpoch;
notifyListeners();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Record sync arrival at the durable-write callback.

Line 2757 can be false because _onDataStored() may already have advanced _lastRecTs. In that ordering, line 2759 does not run and syncingNow stays false while records land.

Set _lastIngestMs in _onDataStored() when the durable write completes. Do not derive arrival from a completed _runSyncBurst session. Add a regression test for this ordering.

As per coding guidelines, “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2757 - 2760, Move the durable-write
arrival timestamp update into _onDataStored(), ensuring _lastIngestMs is set
whenever the write completes even if _lastRecTs was already advanced. Remove the
completed _runSyncBurst-based arrival inference, preserve listener notification
behavior, and add a regression test covering _onDataStored() advancing
_lastRecTs before the sync callback.

Source: Coding guidelines

Comment thread lib/state/app_state.dart Outdated
Comment thread test/sync_dot_test.dart
_onDataStored advances the frontier itself, so by the time the sync burst
checked whether the frontier had moved it never had — the indicator was hung off
a condition that is false exactly when records land. it marks activity at the
durable write instead, which is the one path that sees every commit.

the window also decayed on wall-clock time with nothing notifying at the
boundary, so a band that went quiet left the dot lit until some unrelated change
came along. a one-shot timer closes it, cancelled on dispose.

a negative counter reading could seat itself as the day's baseline, and the next
ordinary reading would report the difference as steps nobody took.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2d10497

@abdulsaheel
abdulsaheel merged commit 5ae1735 into main Aug 8, 2026
3 checks passed
@abdulsaheel
abdulsaheel deleted the fix/steps-midnight-and-sync-dot branch August 8, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant