AI Insight Intelligence
Overview
Two personalized, owner-scoped insights ride the existing Coach insight surface (useAiInsights.ts → AiInsightCard.vue) — no parallel pipeline, no new screen:
- Mood-goal correlation insight (roadmap item 31) — a reflective card noticing when a user's brighter mood days line up with more check-ins on one of their own goals.
- Pace suggestion with one-tap accept (roadmap item 32) — a card offering to move a goal's target date when the user is consistently ahead of or behind their expected check-in pace, with a single tap to apply it (and Undo right after).
Both are private to the user (never exposed on any ally/community/team query — see Privacy scoping) and gated behind their own kill-switch flags at 0% rollout. Shipped in v4.6 Phase 3 (Intelligence & Analytics), roadmap items 31 + 32, OBJ-1509.
This page covers what the two insights do and how they're wired. For the rest of the Coach insight-card system (types, fallback ladder, dismissal, caching), see Coach Features § Insight Cards.
Mood-goal correlation insight
Ai::Coaching::MoodGoalCorrelationService looks at the user's own MoodLog entries and GoalEvent check-ins over a trailing 30-day window and surfaces a single insight when brighter-mood days line up with meaningfully higher check-in activity on one of the user's own active, top-level goals.
Conditions (all owner-scoped, read-only):
- At least 5 mood logs and 5 goal check-ins in the lookback window.
- "Bright" moods are
amazingandhappy. - A goal qualifies when its check-in rate on bright-mood days is at least 1.5× its check-in rate on other days (and the bright-day rate is non-zero).
- Returns the first qualifying goal among the user's active, non-sub goals — not every correlated goal.
Card copy:
- Title: "Your mood moves with this goal"
- Body: "On days you log a brighter mood, you check in on {goal name} about twice as often. Worth protecting those days."
- CTA: "Ask Coach"
Surface: a new moodGoalInsight field on Types::UserType, reusing Types::InsightPackType (same shape as the existing daily insight pack cards — no new frontend type). Frontend: useMoodGoalInsight() (new export in useAiInsights.ts), rendered as a plain AiInsightCard (type="insight") — zero component change, since this insight needs no interactive accept flow. Wired into Dashboard.vue.
Flag: mood_goal_insights_enabled.
Pace suggestion + one-tap accept
Ai::Coaching::PaceSuggestionService detects when one of the user's own goals is consistently ahead of or behind its expected check-in cadence (days_to_update vs. target_date) and computes a conservative timeline adjustment. Thresholds are deliberately conservative to avoid noisy suggestions:
- Goal must be at least 14 days old with at least 5 real check-ins (lifecycle events like "Goal created!" don't count).
- Ahead: actual check-ins ≥ 1.5× the expected count for elapsed time — proposes pulling the target date in.
- Behind: actual check-ins ≤ 0.5× expected — proposes pushing the target date out.
- The computed adjustment must be at least 7 days, or no suggestion is returned.
Card copy (PaceSuggestionCard.vue, states: idle-ahead / idle-behind / applying / applied / error):
- Ahead — title "You're ahead of schedule", body "You've been logging {goal} about {pace summary} faster than planned. Want to pull your target in?", CTA "Move target to {date}".
- Behind — title "Give this goal more room", body "Check-ins on {goal} have slowed lately. A later target keeps it realistic — no pressure."
- Applying — "Updating your timeline…" (spinner,
aria-busy). - Applied — title "Target moved to {date}", body "Your timeline for {goal} is updated.", with an Undo link (
role="status",aria-live="polite"). Auto-dismisses ~6 seconds after applying if Undo isn't clicked. - Error — "Couldn't move your target" / "Your goal is unchanged. Give it another tap." (
role="alert", "Try again").
One-tap accept, no blocking confirm: the concrete date shown on the button (e.g. "Move target to Nov 3") serves as confirmation-by-preview; the post-commit Undo is the reversal mechanism. This was a deliberate decision recorded in the UI-SPEC, not an oversight.
Backend: acceptPaceSuggestion mutation
The accept action is a new GoalTracking::AcceptPaceSuggestion interaction + acceptPaceSuggestion GraphQL mutation — not a reuse of updateGoal — because it applies a computed adjustment and must independently re-verify ownership and re-derive the date server-side:
mutation AcceptPaceSuggestionMutation($goalId: ID!) {
acceptPaceSuggestion(goalId: $goalId) {
goal { publicId targetDateTime }
previousTargetDate
proposedTargetDate
errors
}
}- Takes only a
goal_id(public_id) — the proposed date is never trusted from the client. The interaction re-runsPaceSuggestionServiceagainst the goal server-side (call(goal:), which itself re-verifiesgoal.user_id == user.id) and applies whatever it computes. - Guards, in order: goal exists → current user owns the goal (or is an admin) →
goal_auto_adjust_enabledis on for the goal's owner → a pace suggestion still exists for the goal. - Returns
previousTargetDate(the goal's target date immediately before this change) specifically so the frontend can undo accurately — see Undo correctness below.
Undo reuses the existing updateGoal mutation (no new undo mutation) rather than a dedicated revert path.
Surface: a new paceSuggestion field on Types::UserType (Types::PaceSuggestionType). Frontend: usePaceSuggestion() (new export in useAiInsights.ts), rendered by the new PaceSuggestionCard.vue. Wired into Goals.vue.
Flag: goal_auto_adjust_enabled.
Undo correctness: uses the mutation's own previousTargetDate
PaceSuggestionCard.vue's handleAccept captures previousTargetDate from the mutation's response into a local variable and handleUndo reverts using that value — not the currentTargetDate on the original suggestion prop. This matters because the suggestion the card is holding may have been fetched before accept ran (Apollo cache, no forced refetch); if the goal's real target date had changed in between, undoing against the stale prop value would silently corrupt the goal's timeline instead of truly reverting it. An earlier version of this fix used the prop value and was caught in review (see PR #1595) before merge.
Privacy scoping
Both moodGoalInsight and paceSuggestion are fields on the general-purpose Types::UserType — the same type backing UserSearchResultType, GoalType#user, CommunityMemberType#user, TeamMembershipType#user, admin_search_users, and every other place a User node appears, not just me. Both resolvers guard against being rendered for anyone other than the current user:
def mood_goal_insight
return nil unless object.id == context[:current_user]&.id
Ai::Coaching::MoodGoalCorrelationService.new(object).call
end(pace_suggestion is guarded identically.) Because the guard lives on the base UserType resolver, it protects every path that type backs uniformly — a query like searchUsers { edges { node { moodGoalInsight { message } } } } or goal(id: sharedGoalId) { user { paceSuggestion { proposedTargetDate } } } for a different user returns null, not that user's private data. This is covered by a dedicated regression spec (mood_goal_and_pace_insight_fields_spec.rb, "INSIGHT-PRIVACY: non-me UserType paths") that queries both fields through searchUsers for a user with live correlated data and both flags on, asserting null — the original spec suite only exercised the me { ... } path, where object already equals current_user, so it couldn't have caught a cross-user leak on its own.
Both services also scope every lookup through user.mood_logs / user.goals internally, and PaceSuggestionService#call(goal:) re-verifies goal.user_id == user.id even when a specific goal is passed in (the path the accept mutation uses) — never trusting a client-controlled goal_id without re-checking ownership.
Feature flags
Flags: mood_goal_insights_enabled and goal_auto_adjust_enabled — registered in ionic_frontend/src/lib/featureFlags.ts, synced to PostHog project 368400 (created disabled, 0% rollout).
Fail-closed at every layer:
- Service: both
Ai::Coaching::MoodGoalCorrelationService#callandPaceSuggestionService#callcheckFeatureFlagService.enabled?first and returnnilotherwise. - Interaction guard:
AcceptPaceSuggestionrejects the mutation server-side (FORBIDDEN) whengoal_auto_adjust_enabledis off for the goal's owner. - GraphQL field: both
UserTypefields arenull: trueand returnnilwhen their flag is off (in addition to the current-user guard above). - Client composable:
useMoodGoalInsight()/usePaceSuggestion()checkisFeatureFlagEnabled()before returning a non-null insight/suggestion or firing a fetch.
See Feature Flags for the full lifecycle (introduce → rollout → GA → retire).
Backend: GraphQL surface
Full field-level reference: GraphQL API Reference § Types and § Mutations.
| Field / Mutation | Type | Notes |
|---|---|---|
User#moodGoalInsight | InsightPack (nullable) | Reuses the existing insight-pack shape; owner-scoped, fail-closed. |
User#paceSuggestion | PaceSuggestion (nullable) | New type: id, goalPublicId, goalName, direction, currentTargetDate, proposedTargetDate, paceSummary. |
acceptPaceSuggestion(goalId: ID!) | AcceptPaceSuggestionPayload | Returns goal, previousTargetDate, proposedTargetDate, errors. |
Frontend implementation
useAiInsights.ts—useMoodGoalInsight()andusePaceSuggestion(), following the same computed-insight + dismiss pattern as the rest of the composable. Dismissal reuses the existingai-insights-dismissedlocalStorage dedup (24-hour hide window), shared with the daily insight pack cards.AiInsightCard.vue— unchanged; renders the mood-goal card astype="insight"data.PaceSuggestionCard.vue(new) — owns the full idle → applying → applied(+Undo)/error state machine. Visually matches theAiInsightCardshell (coach tint, left accent bar, gradient icon circle) so it reads as one system, with its own Storybook story (PaceSuggestionCard.stories.ts) covering every state via a Storybook-onlypreviewStateprop.- GraphQL query fields (
moodGoalInsight,paceSuggestion) added toCURRENT_INSIGHTS_QUERY, and the newACCEPT_PACE_SUGGESTION_MUTATION, both inionic_frontend/src/constants/graphql/ai.js. - Accept button uses
bg-coach-interactive(AA-contrast token), not the lighterbg-coachthe existing insight CTA uses — a consequential mutation clears AA on purpose.
Known limitations
goal-detailsurface not wired. The original task package listedgoal-detailalongsideDashboard.vue/Goals.vueas a candidate surface for the pace card; this phase scoped to one canonical surface per insight (Dashboard for mood, Goals for pace) to keep the diff surgical. WiringusePaceSuggestion()+PaceSuggestionCardintoGoalCoachSuggests.vue/Goal.vueis a small follow-up if product wants pace suggestions there too.- UI evidence is Storybook-only. The screenshots gathered during this phase are isolated component renders against the Storybook build, not the full app with real auth + seeded
MoodLog/GoalEventhistory.
Last updated: 2026-07-20