Symptom
The AI Coach insists there is no workout session for "today" and reports that the latest row in v_sessions is yesterday's, even when a workout was completed today. Reported by a user 22 Jul 2026 (their session did not appear; coach said the newest session was 21 Jul).
Verified against current main (post workout/health refactor); file:line references below are re-derived from the current tree.
Root cause: v_sessions has no local-day column, and the coach is never told to convert epoch → local
Every other view the coach can read is already keyed by a local day label:
v_daily(date, …), v_metric(date, …), v_series(date, …), v_hypnogram(date, …), v_insights(…, date, …) — date is a device-local YYYY-MM-DD label. The coach filters WHERE date='YYYY-MM-DD' and gets correct local-day data.
v_sessions is the exception. It is a raw passthrough of the sessions table and exposes only epoch timestamps, no day label:
-- lib/data/db.dart:1171-1175
CREATE VIEW v_sessions AS
SELECT id, start_ts, end_ts, type, status, calories, strain, max_hr,
duration_min, steps, hrr_bpm, source, zone_min_json
FROM sessions
The coach system prompt (lib/coach/coach_prompt.dart:51-52, mirrored in lib/coach/coach_engine.dart:711) documents these as "timestamps are epoch SECONDS" and injects "Today is " (lib/coach/coach_engine.dart:396, via _today() → the local todayLabel() helper at :377). But it gives the model no instruction to convert a session's epoch to the local calendar day, and there is no local-day column to filter on. So to answer "did I train today?" the model must write something like:
SELECT * FROM v_sessions WHERE date(start_ts, 'unixepoch') = '2026-07-22'
SQLite's date(ts,'unixepoch') evaluates in UTC — the 'localtime' modifier is required for a local day, and the app's own code uses it everywhere it buckets sessions by day (e.g. lib/data/db.dart:2445: strftime('%Y-%m-%d', start_ts, 'unixepoch', 'localtime'); and lib/data/local_repository_impl.dart:1585 — the local _dayLabelOf helper, used at :1536). The coach's surface omits it, so a session lands under the wrong local day whenever the local date differs from the UTC date at the session's timestamp. For a user east of UTC an early-local-day session maps to the previous UTC date → "latest session is yesterday", exactly the reported symptom.
This is the same UTC-vs-local day-label class as #26 (sleep), fixed by routing day labels through the local dayLabelOf()/todayLabel() helper (lib/data/day_label.dart).
The session write path is fine — sessions store absolute epoch and are persisted with status='done' on stop and on confirm/import:
- manual/live stop:
lib/state/app_state.dart:3088-3104 (stopWorkout → putSession({… 'status':'done' …}));
- a confirmed auto-detected suggestion:
lib/ui/workouts/workouts_screen.dart:507-521 (_logDetectedSession → putSession({… 'status':'done', 'source':'auto' …}), reached from _confirmSuggestion at :200).
So if the row exists, it exists with a correct epoch; the coach just can't reliably ask for it by local day.
Recommended fix (confidence: medium)
Give the coach a local-day column so it never has to convert:
CREATE VIEW v_sessions AS
SELECT id, start_ts, end_ts,
strftime('%Y-%m-%d', start_ts, 'unixepoch', 'localtime') AS date,
type, status, calories, strain, max_hr,
duration_min, steps, hrr_bpm, source, zone_min_json
FROM sessions
and document v_sessions(date, …) in coach_prompt.dart / coach_engine.dart so the model filters WHERE date='YYYY-MM-DD' like every other view. (Alternatively/additionally, instruct the prompt to always append 'localtime' when converting start_ts, but a date column is the consistent, less error-prone fix.)
Confidence medium: the schema/prompt gap is real and deterministic, but the exact SQL is emitted by the LLM, so which query it writes (and thus the off-by-one direction) depends on the model and the user's timezone.
Needs on-device / real-timezone confirmation — and rule out the more likely cause first
Most likely for this reporter: the workout was auto-detected and never confirmed. Detected bouts are opt-in — the engine writes them to the workout_suggestions table (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" (workouts_screen.dart:200 → :507, comment at :523-524: "We never auto-log"). If RR never confirmed, v_sessions genuinely has no row for today and the coach's "latest is yesterday" is literally correct — this UTC-mis-dating mechanism is then moot, and the real gap is the confirmation UX (shared with the Apple Health symptom in #130). So before treating this as the day-label bug, confirm a sessions row for today actually exists.
- Query
SELECT id, source, status, start_ts FROM sessions ORDER BY start_ts DESC LIMIT 5 — is there a row whose start_ts is today (local)? If no row → this is the unconfirmed-suggestion gap, not the day-label bug.
- If a row does exist: capture the actual
run_sql the coach emitted for "today's session" — does it use date(start_ts,'unixepoch') without 'localtime'? Plus the reporter's timezone and the session's local start time (to confirm the UTC boundary flips it to "yesterday").
Relationship to the Apple Health symptom (#130)
Two independent code roots when a session row exists: this coach path reads the sessions table via v_sessions and mis-buckets by UTC; #130's HealthKit path is gated on today's day_result derivation. They converge only in the unconfirmed-detected-workout case, where the single shared root is "no sessions row was ever persisted" — see the confirmation-gate note above and in #130.
Symptom
The AI Coach insists there is no workout session for "today" and reports that the latest row in
v_sessionsis yesterday's, even when a workout was completed today. Reported by a user 22 Jul 2026 (their session did not appear; coach said the newest session was 21 Jul).Root cause:
v_sessionshas no local-day column, and the coach is never told to convert epoch → localEvery other view the coach can read is already keyed by a local day label:
v_daily(date, …),v_metric(date, …),v_series(date, …),v_hypnogram(date, …),v_insights(…, date, …)—dateis a device-localYYYY-MM-DDlabel. The coach filtersWHERE date='YYYY-MM-DD'and gets correct local-day data.v_sessionsis the exception. It is a raw passthrough of thesessionstable and exposes only epoch timestamps, no day label:The coach system prompt (
lib/coach/coach_prompt.dart:51-52, mirrored inlib/coach/coach_engine.dart:711) documents these as "timestamps are epoch SECONDS" and injects "Today is " (lib/coach/coach_engine.dart:396, via_today()→ the localtodayLabel()helper at:377). But it gives the model no instruction to convert a session's epoch to the local calendar day, and there is no local-day column to filter on. So to answer "did I train today?" the model must write something like:SQLite's
date(ts,'unixepoch')evaluates in UTC — the'localtime'modifier is required for a local day, and the app's own code uses it everywhere it buckets sessions by day (e.g.lib/data/db.dart:2445:strftime('%Y-%m-%d', start_ts, 'unixepoch', 'localtime'); andlib/data/local_repository_impl.dart:1585— the local_dayLabelOfhelper, used at:1536). The coach's surface omits it, so a session lands under the wrong local day whenever the local date differs from the UTC date at the session's timestamp. For a user east of UTC an early-local-day session maps to the previous UTC date → "latest session is yesterday", exactly the reported symptom.This is the same UTC-vs-local day-label class as #26 (sleep), fixed by routing day labels through the local
dayLabelOf()/todayLabel()helper (lib/data/day_label.dart).The session write path is fine — sessions store absolute epoch and are persisted with
status='done'on stop and on confirm/import:lib/state/app_state.dart:3088-3104(stopWorkout→putSession({… 'status':'done' …}));lib/ui/workouts/workouts_screen.dart:507-521(_logDetectedSession→putSession({… 'status':'done', 'source':'auto' …}), reached from_confirmSuggestionat:200).So if the row exists, it exists with a correct epoch; the coach just can't reliably ask for it by local day.
Recommended fix (confidence: medium)
Give the coach a local-day column so it never has to convert:
and document
v_sessions(date, …)incoach_prompt.dart/coach_engine.dartso the model filtersWHERE date='YYYY-MM-DD'like every other view. (Alternatively/additionally, instruct the prompt to always append'localtime'when convertingstart_ts, but adatecolumn is the consistent, less error-prone fix.)Confidence medium: the schema/prompt gap is real and deterministic, but the exact SQL is emitted by the LLM, so which query it writes (and thus the off-by-one direction) depends on the model and the user's timezone.
Needs on-device / real-timezone confirmation — and rule out the more likely cause first
Most likely for this reporter: the workout was auto-detected and never confirmed. Detected bouts are opt-in — the engine writes them to the
workout_suggestionstable (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" (workouts_screen.dart:200→:507, comment at:523-524: "We never auto-log"). If RR never confirmed,v_sessionsgenuinely has no row for today and the coach's "latest is yesterday" is literally correct — this UTC-mis-dating mechanism is then moot, and the real gap is the confirmation UX (shared with the Apple Health symptom in #130). So before treating this as the day-label bug, confirm asessionsrow for today actually exists.SELECT id, source, status, start_ts FROM sessions ORDER BY start_ts DESC LIMIT 5— is there a row whosestart_tsis today (local)? If no row → this is the unconfirmed-suggestion gap, not the day-label bug.run_sqlthe coach emitted for "today's session" — does it usedate(start_ts,'unixepoch')without'localtime'? Plus the reporter's timezone and the session's local start time (to confirm the UTC boundary flips it to "yesterday").Relationship to the Apple Health symptom (#130)
Two independent code roots when a session row exists: this coach path reads the
sessionstable viav_sessionsand mis-buckets by UTC; #130's HealthKit path is gated on today'sday_resultderivation. They converge only in the unconfirmed-detected-workout case, where the single shared root is "nosessionsrow was ever persisted" — see the confirmation-gate note above and in #130.