Skip to content

AI Insight Intelligence

Overview

Two personalized, owner-scoped insights ride the existing Coach insight surface (useAiInsights.tsAiInsightCard.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 amazing and happy.
  • 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:

graphql
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-runs PaceSuggestionService against the goal server-side (call(goal:), which itself re-verifies goal.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_enabled is 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:

ruby
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#call and PaceSuggestionService#call check FeatureFlagService.enabled? first and return nil otherwise.
  • Interaction guard: AcceptPaceSuggestion rejects the mutation server-side (FORBIDDEN) when goal_auto_adjust_enabled is off for the goal's owner.
  • GraphQL field: both UserType fields are null: true and return nil when their flag is off (in addition to the current-user guard above).
  • Client composable: useMoodGoalInsight() / usePaceSuggestion() check isFeatureFlagEnabled() 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 / MutationTypeNotes
User#moodGoalInsightInsightPack (nullable)Reuses the existing insight-pack shape; owner-scoped, fail-closed.
User#paceSuggestionPaceSuggestion (nullable)New type: id, goalPublicId, goalName, direction, currentTargetDate, proposedTargetDate, paceSummary.
acceptPaceSuggestion(goalId: ID!)AcceptPaceSuggestionPayloadReturns goal, previousTargetDate, proposedTargetDate, errors.

Frontend implementation

  • useAiInsights.tsuseMoodGoalInsight() and usePaceSuggestion(), following the same computed-insight + dismiss pattern as the rest of the composable. Dismissal reuses the existing ai-insights-dismissed localStorage dedup (24-hour hide window), shared with the daily insight pack cards.
  • AiInsightCard.vue — unchanged; renders the mood-goal card as type="insight" data.
  • PaceSuggestionCard.vue (new) — owns the full idle → applying → applied(+Undo)/error state machine. Visually matches the AiInsightCard shell (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-only previewState prop.
  • GraphQL query fields (moodGoalInsight, paceSuggestion) added to CURRENT_INSIGHTS_QUERY, and the new ACCEPT_PACE_SUGGESTION_MUTATION, both in ionic_frontend/src/constants/graphql/ai.js.
  • Accept button uses bg-coach-interactive (AA-contrast token), not the lighter bg-coach the existing insight CTA uses — a consequential mutation clears AA on purpose.

Known limitations

  • goal-detail surface not wired. The original task package listed goal-detail alongside Dashboard.vue/Goals.vue as 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. Wiring usePaceSuggestion() + PaceSuggestionCard into GoalCoachSuggests.vue/Goal.vue is 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/GoalEvent history.

Last updated: 2026-07-20

Loading…