v1.23 — Coach Message Trigger Redesign
Coach is now temporally honest — daily messages reflect the user's actual day, generated on-demand rather than at 06:00 UTC.
Summary
Before this milestone, the Coach surface showed goals as "due today" even after the user had already logged check-ins for them. The root cause was two-fold: Goal#due_today? did not compose with the check-in state, and the coach-home synopsis was generated once daily at 06:00 UTC for all eligible users — regardless of whether they opened the app or completed their habits. Active users riding the pre-baked cache saw stale copy all day.
This milestone shipped in five phases over seven days. Phase 113 fixed the predicate bug structurally (Goal#due_today? now returns false immediately when checked_in_today? is true). Phases 114–116 built the event-driven trigger architecture: a required-checkins-complete signal, a daily-load trigger that generates the coach-home message on first app open, and a completion follow-up that overwrites it once all required habits are logged. Phase 117 completed the architecture by removing coach-home from the 06:00 UTC pre-bake job — the surface is now fully event-driven.
The outcome is fresher Coach copy that scales with actual user activity rather than total eligible users. The two-trigger model (morning load + completion follow-up) gives each message distinct intent, and the user-local-day cache key means users in any timezone see copy that reflects their actual day.
Goal
Fix the coach-home surface so that daily messages are generated on-demand from real user activity rather than pre-baked once at 06:00 UTC, and so that the due_today? predicate correctly excludes habits the user has already checked in.
Scope — What Shipped
Goal#due_today?composes withchecked_in_today?: returns false for all recurrence types when a same-day check-in is logged (Phase 113)Goal#expected_today?predicate — recurrence calendar without the check-in guard (Phase 114)User#required_checkins_complete_today?aggregate signal — true when all habits expected today are checked in, or when no habits are scheduled today (Phase 114)User.requiredCheckinsCompleteTodayGraphQL field onUserType(Phase 114)Ai::EnsureTodaysCoachMessageinteraction +ensureTodaysCoachMessageGraphQL mutation — idempotent daily-load trigger with user-local-day cache key (Phase 115)CoachSynopsisCard.vuewired to fireensureTodaysCoachMessageon mount withmutationPendingskeleton guard (Phase 115)Ai::Subscribersmodule — boot-time subscriber ongoal_tracking.habit_checked_inevents (Phase 116)AiCoaching::GenerateFollowUpCoachMessageJob+Ai::EnsureCompletionFollowUpCoachMessage— completion follow-up trigger (Phase 116)- Source discriminator field added to coach-home cache value:
'daily_load'vs'completion_followup'(Phase 116) - Removed coach-home from
AiCoaching::GenerateDailyInsightPackJobPAGESconstant (Phase 117) UserType#currentInsightsresolver reads coach-home via explicit user-local-day key (Phase 117)AiCoaching::DailyTriggerEventtelemetry wired for both triggers
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 113 | Goal#due_today? bug fix | Shipped | 1 | Goal#due_today? now returns false when checked_in_today? is true for all recurrence types. PR #1126 |
| 114 | Required-checkins-complete signal | Shipped | 1 | expected_today? predicate + User#required_checkins_complete_today? aggregate + GraphQL field. PR #1127 |
| 115 | Daily-load trigger | Shipped | 2 | Ai::EnsureTodaysCoachMessage + ensureTodaysCoachMessage mutation + CoachSynopsisCard wire-up. PRs #1128, #1129 |
| 116 | Completion follow-up trigger | Shipped | 1 | Subscriber + job + EnsureCompletionFollowUpCoachMessage; source discriminator in cache. PR #1130 |
| 117 | Deprecate 06:00 UTC pre-bake | Shipped | 1 | Removed coach-home from GenerateDailyInsightPackJob PAGES; currentInsights resolver reads it via user-local-day key. PR #1131 |
Key Decisions
- Bug fix beachhead first (Phase 113) — Shipped the
Goal#due_today?predicate fix before the architecture shift. Users saw the correct behavior before the trigger redesign landed, and Phase 113's fix made the predicate invariant the foundation everything else built on. - User-local-day cache key — All coach-home triggers key on the user's local date via
timezone_or_default, not UTC. The pre-bake job used UTC dates for all pages; Phase 115 introduced the first user-local-day key for the coach-home surface. - Follow-up overwrites, not stacks — The completion follow-up shares the same cache key as the morning message, overwriting in place. Confirmed as A1 at planning time to keep the coach-home slot simple.
- Removed only the coach-home arm of
GenerateDailyInsightPackJob— Other pages (dashboard, goals, achievements, communities, goal-detail) continue to pre-bake. Scoping the removal narrowly avoided disrupting the existing pre-bake economics for non-coach-home surfaces. Interaction::Basefor both triggers —EnsureTodaysCoachMessageandEnsureCompletionFollowUpCoachMessageboth inheritInteraction::Base(this repo's custom Interaction gem, NOTActiveInteraction) to match theCheckInHabitprecedent, giving idempotent validated-input contracts.
Requirements Coverage
7 / 7 requirements satisfied. All phases shipped; all definition-of-done criteria from the ROADMAP are met.
No separate REQUIREMENTS.md was authored for this milestone; requirements are embedded in the ROADMAP's Definition of Done section.
Full requirements source: v1.23-coach-message-trigger-redesign-ROADMAP.md
Outcomes
- Coach copy for the coach-home surface reflects the user's actual day — the morning message is generated on first app open, the follow-up updates it when all required habits are done.
- LLM generation cost for coach-home scales with active users on their local day, not eligible users on the UTC day.
Goal#due_today?is structurally safe: the model-level predicate guarantees false for checked-in habits regardless of caller.- Telemetry (
ai_coaching.daily_triggerPostHog event) gives visibility into which trigger source generated each message.
Tech Debt
- (Phase 113)
goal.rb:109,120duplicateneeds_reminder?definitions — pre-existing, tidy-up pass owed. - (Phase 113) Frontend double-guard in
Goal.vue:99,103(!!goal.dueToday && !goal.checkedInToday) — harmless, note for future maintainers. - (Phase 114) Potential N+1 on
goal.userinsideUser#required_checkins_complete_today?— AR query cache mitigates in-request; defensiveincludes(:user)suggested for background jobs. - (Phase 115) Vitest mutation error path not exercised in
CoachSynopsisCard.spec.ts— pick up in next code-review pass. - (Phase 116) Round-1 Roy review needed 4 fixes on
EnsureCompletionFollowUpCoachMessage— telemetry contract and inline-rationale requirements now explicit in Orion task packages.
Related Artifacts
- Roadmap: v1.23-coach-message-trigger-redesign-ROADMAP.md
- Phase artifacts: .planning/milestones/v1.23-coach-message-trigger-redesign/
- Git tag: v3.12.5
- Merge PRs:
Related Commits
80e5933c6— fix(goal): due_today? returns false when checked in today (Phase 113, PR #1126)b0de0f073— feat(goal): add expected_today? + required_checkins_complete_today? signal (Phase 114, PR #1127)8b7a798e8— feat(ai): add Ai::EnsureTodaysCoachMessage interaction + mutation + telemetry (Phase 115, PR #1128)e8897b3a9— feat(coach): wire ensureTodaysCoachMessage mutation into CoachSynopsisCard (Phase 115, PR #1129)244dc8666— feat(coach): Phase 116 completion follow-up trigger (PR #1130)1d1275897— feat(coach): Phase 117 — deprecate coach-home pre-bake (PR #1131)
Last updated: 2026-08-07