Skip to content

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 with checked_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.requiredCheckinsCompleteToday GraphQL field on UserType (Phase 114)
  • Ai::EnsureTodaysCoachMessage interaction + ensureTodaysCoachMessage GraphQL mutation — idempotent daily-load trigger with user-local-day cache key (Phase 115)
  • CoachSynopsisCard.vue wired to fire ensureTodaysCoachMessage on mount with mutationPending skeleton guard (Phase 115)
  • Ai::Subscribers module — boot-time subscriber on goal_tracking.habit_checked_in events (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::GenerateDailyInsightPackJob PAGES constant (Phase 117)
  • UserType#currentInsights resolver reads coach-home via explicit user-local-day key (Phase 117)
  • AiCoaching::DailyTriggerEvent telemetry wired for both triggers

Phases

PhaseNameStatusPlansHighlights
113Goal#due_today? bug fixShipped1Goal#due_today? now returns false when checked_in_today? is true for all recurrence types. PR #1126
114Required-checkins-complete signalShipped1expected_today? predicate + User#required_checkins_complete_today? aggregate + GraphQL field. PR #1127
115Daily-load triggerShipped2Ai::EnsureTodaysCoachMessage + ensureTodaysCoachMessage mutation + CoachSynopsisCard wire-up. PRs #1128, #1129
116Completion follow-up triggerShipped1Subscriber + job + EnsureCompletionFollowUpCoachMessage; source discriminator in cache. PR #1130
117Deprecate 06:00 UTC pre-bakeShipped1Removed 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::Base for both triggersEnsureTodaysCoachMessage and EnsureCompletionFollowUpCoachMessage both inherit Interaction::Base (this repo's custom Interaction gem, NOT ActiveInteraction) to match the CheckInHabit precedent, 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_trigger PostHog event) gives visibility into which trigger source generated each message.

Tech Debt

  • (Phase 113) goal.rb:109,120 duplicate needs_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.user inside User#required_checkins_complete_today? — AR query cache mitigates in-request; defensive includes(: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.
  • 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

Loading…