Skip to content

Today's workout not written to Apple Health: HealthKit export is day_result-gated, not session-triggered #130

Description

@dannymcc

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:

  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions