Symptom
Edge does not write today's completed workout to Apple Health, even after a manual/forced sync. Reported alongside the coach "no session today" symptom, 22 Jul 2026.
Verified against current main (post workout/health refactor); file:line references below are re-derived from the current tree.
Root cause: the HealthKit workout write is day-result-gated, not session-triggered
Workouts are written to the health store only from inside _exportDay(date, bundle) (lib/health/health_export.dart:538-571), which iterates LocalDb.sessionsInRange(localMidnight(date), localMidnightNext-1) and writes each non-live session for that local day. Correct as far as it goes — the day window is built in local time (_localMidnight, DST-safe next-midnight at :326-332, :641-647), so no timezone bug in the write itself.
The problem is when _exportDay runs. exportAll() (lib/health/health_export.dart:218-322) drives it by iterating recentDayResults(400) — i.e. derived day_result rows — and calls _exportDay(date, bundle) for a date only if:
date is past the finalized-export cursor (health_export_through), AND
- a
day_result row for date exists with a non-null, non-skipped bundle (health_export.dart:234-245), AND
- that day isn't currently within its retry back-off / attempt-cap window (
health_export.dart:247-293).
So a workout only reaches Apple Health as a side effect of exporting a derived day. Finishing a workout does not itself create or refresh today's day_result, and nothing in the finish path calls exportAll() for the just-finished session. stopWorkout() (lib/state/app_state.dart:3063) persists the session (putSession({… 'status':'done' …}) at :3088-3104) and then calls forceResync() (:3114); forceResync() (:2444) pulls band flash and markStoredData() to schedule a derive. Health export then runs only off the post-drain derive path (lib/state/app_state.dart:769-778, the _healthExport.exportAll() call at :771) — and only exports date='today' if today already has an exportable day_result bundle. The manual "sync" entry point is the same: healthSyncNow() → exportAll() (app_state.dart:357-358).
Consequently, if today has no day_result yet (band not actively syncing / today's derive hasn't produced a bundle), or today's bundle is skipped (pathological-day skip marker), or today is backing off after an earlier failed write (any single sub-write throwing flips success=false, so the whole day — workout included — backs off for up to 24h; health_export.dart:263-293), then _exportDay('2026-07-22', …) never runs and today's workout is never written — and pressing "sync" won't fix it, because the session's presence in sessions is irrelevant to whether the day gets exported.
Recommended fix (confidence: medium)
Decouple the workout write from the day-result gate. Options:
- After a session is finalised (
stopWorkout / suggestion-confirm / import), export that session directly to the health store (a small exportSession() doing the delete-then-write for just the workout's window), independent of whether today's day_result exists yet. Matches the honesty/idempotency model already in _exportDay.
- Or, in
exportAll(), also sweep sessionsInRange for the recent tail (e.g. last N days) and write any finalised session whose day was skipped / has no bundle, so a workout is never withheld purely because the day wasn't derived/exported.
Confidence medium: the gating is unambiguous from the code; which of the three "today not exported" conditions the reporter actually hit (no bundle vs skipped vs back-off) needs device logs.
Needs on-device confirmation — and rule out the more likely cause first
Most likely for this reporter: the workout was auto-detected and never confirmed, so there is no sessions row at all. Detected bouts are opt-in — the engine writes them to workout_suggestions (lib/compute/derivation_engine.dart:1575-1592, :3076) and shows a "did you work out?" card; nothing becomes a sessions row until the user taps "Log it" (lib/ui/workouts/workouts_screen.dart:200 → :507-521, comment :523-524: "We never auto-log"). If RR never confirmed, sessionsInRange for today is empty and the workout is never written regardless of the day-result gate — this then shares the coach's root (#129), not the gating root above.
SELECT id, source, status, start_ts FROM sessions ORDER BY start_ts DESC LIMIT 5 — is there a done row for today (local)? If no → unconfirmed-suggestion gap, not this gating bug.
- If a row exists: whether
2026-07-22 had a day_result row at the time of the forced sync, and if so whether its bundle was skipped.
[health] debug lines around the sync — exported N day(s); finalized-cursor=…, any export incomplete (attempt …) back-off entries for today, thrown-write logs.
- That HealthKit write permission for workouts is granted (an ungranted write silently no-ops — it does not throw and does not trigger the back-off path, per the comment above
_ensureConfigured at :74-79).
Relationship to the AI Coach symptom (#129)
Two independent code roots when a session row exists: the coach reads the sessions table via v_sessions (only epoch, no local-day column → mis-buckets by UTC); this path is gated on today's day_result derivation. Neither touches the other. They converge only in the unconfirmed-detected-workout case, where the single shared root is "no sessions row was ever persisted" (opt-in confirmation gate) — rule that out on-device first.
Related: #129 (AI Coach "no session today" — independent root when the row exists; shared root only if the detected workout was never confirmed).
Symptom
Edge does not write today's completed workout to Apple Health, even after a manual/forced sync. Reported alongside the coach "no session today" symptom, 22 Jul 2026.
Root cause: the HealthKit workout write is day-result-gated, not session-triggered
Workouts are written to the health store only from inside
_exportDay(date, bundle)(lib/health/health_export.dart:538-571), which iteratesLocalDb.sessionsInRange(localMidnight(date), localMidnightNext-1)and writes each non-livesession for that local day. Correct as far as it goes — the day window is built in local time (_localMidnight, DST-safe next-midnight at:326-332,:641-647), so no timezone bug in the write itself.The problem is when
_exportDayruns.exportAll()(lib/health/health_export.dart:218-322) drives it by iteratingrecentDayResults(400)— i.e. derivedday_resultrows — and calls_exportDay(date, bundle)for adateonly if:dateis past the finalized-export cursor (health_export_through), ANDday_resultrow fordateexists with a non-null, non-skippedbundle (health_export.dart:234-245), ANDhealth_export.dart:247-293).So a workout only reaches Apple Health as a side effect of exporting a derived day. Finishing a workout does not itself create or refresh today's
day_result, and nothing in the finish path callsexportAll()for the just-finished session.stopWorkout()(lib/state/app_state.dart:3063) persists the session (putSession({… 'status':'done' …})at:3088-3104) and then callsforceResync()(:3114);forceResync()(:2444) pulls band flash andmarkStoredData()to schedule a derive. Health export then runs only off the post-drain derive path (lib/state/app_state.dart:769-778, the_healthExport.exportAll()call at:771) — and only exportsdate='today'if today already has an exportableday_resultbundle. The manual "sync" entry point is the same:healthSyncNow()→exportAll()(app_state.dart:357-358).Consequently, if today has no
day_resultyet (band not actively syncing / today's derive hasn't produced a bundle), or today's bundle isskipped(pathological-day skip marker), or today is backing off after an earlier failed write (any single sub-write throwing flipssuccess=false, so the whole day — workout included — backs off for up to 24h;health_export.dart:263-293), then_exportDay('2026-07-22', …)never runs and today's workout is never written — and pressing "sync" won't fix it, because the session's presence insessionsis irrelevant to whether the day gets exported.Recommended fix (confidence: medium)
Decouple the workout write from the day-result gate. Options:
stopWorkout/ suggestion-confirm / import), export that session directly to the health store (a smallexportSession()doing the delete-then-write for just the workout's window), independent of whether today'sday_resultexists yet. Matches the honesty/idempotency model already in_exportDay.exportAll(), also sweepsessionsInRangefor the recent tail (e.g. last N days) and write any finalised session whose day was skipped / has no bundle, so a workout is never withheld purely because the day wasn't derived/exported.Confidence medium: the gating is unambiguous from the code; which of the three "today not exported" conditions the reporter actually hit (no bundle vs skipped vs back-off) needs device logs.
Needs on-device confirmation — and rule out the more likely cause first
Most likely for this reporter: the workout was auto-detected and never confirmed, so there is no
sessionsrow at all. Detected bouts are opt-in — the engine writes them toworkout_suggestions(lib/compute/derivation_engine.dart:1575-1592,:3076) and shows a "did you work out?" card; nothing becomes asessionsrow until the user taps "Log it" (lib/ui/workouts/workouts_screen.dart:200→:507-521, comment:523-524: "We never auto-log"). If RR never confirmed,sessionsInRangefor today is empty and the workout is never written regardless of the day-result gate — this then shares the coach's root (#129), not the gating root above.SELECT id, source, status, start_ts FROM sessions ORDER BY start_ts DESC LIMIT 5— is there adonerow for today (local)? If no → unconfirmed-suggestion gap, not this gating bug.2026-07-22had aday_resultrow at the time of the forced sync, and if so whether its bundle wasskipped.[health]debug lines around the sync —exported N day(s); finalized-cursor=…, anyexport incomplete (attempt …)back-off entries for today, thrown-write logs._ensureConfiguredat:74-79).Relationship to the AI Coach symptom (#129)
Two independent code roots when a session row exists: the coach reads the
sessionstable viav_sessions(only epoch, no local-day column → mis-buckets by UTC); this path is gated on today'sday_resultderivation. Neither touches the other. They converge only in the unconfirmed-detected-workout case, where the single shared root is "nosessionsrow was ever persisted" (opt-in confirmation gate) — rule that out on-device first.Related: #129 (AI Coach "no session today" — independent root when the row exists; shared root only if the detected workout was never confirmed).