Skip to content

Goals — Feature & Testing Guide

Related docs:

Overview

Goals are the core entity in Objectuve. Users create goals with a name, optional description, category, type, target date, milestones, and cover image. Progress is tracked through goal events (updates with text, media, and mood). Goals support parent-child nesting via milestones and can be made public for social accountability.


Creating a Goal

Via the Goal Form

  1. Tap + (create goal) from the Dashboard or Goals page
  2. Enter a name (required) and optional description
  3. In Quick Mode, tap Create Goal to save immediately
  4. Or tap Expand Details to reveal full configuration:
FieldTypeRequiredDescription
NameTextYesGoal title
DescriptionTextareaNoMotivation or context
Identity Prompt ("Who you're becoming")Textarea, max 240 charsNoFree-text identity framing (e.g. "A consistent runner who shows up even on the hard days"). Personalizes Coach messaging when present — see Coach personalization below. Always visible in the create form.
Past Attempt Context ("Tried this before?")Textarea, max 200 chars, behind a disclosure toggleNoFree-text context on a prior attempt at this goal (e.g. "Started strong but fell off around week 3"). Personalizes Coach messaging when present. Collapsed by default; auto-expands on edit if the goal already has a saved value.
Goal TypeSelectionNoFetched via GOAL_KINDS_QUERY (e.g., Habit, Milestone, Quantity)
CategoryButton GroupNoFitness, Learning, etc. (GOAL_CATEGORIES_QUERY)
Target DateDate PickerNoDefaults to 14 days from now
Days to UpdateNumber (1–365)NoHow often you should log progress (default: 14)
Cover ImageImage UploadNoHero image via ActiveStorage (GCS in production)
RecurrenceButton GroupNoConverts goal to habit (see habits.md)
  1. Optionally add milestones (sub-goals) via the Milestone Manager
  2. Optionally tap Auto-Generate to create AI-suggested milestones (requires name > 3 chars)
  3. Tap Create Goal to save

What Gets Stored

  • name: Goal title (required)
  • content: Description text
  • identity_prompt: Optional free-text identity framing, max 240 chars. Nilable at every layer — no backfill for pre-v3.12 goals.
  • past_attempt_context: Optional free-text prior-attempt context, max 200 chars. Nilable at every layer — no backfill for pre-v3.12 goals.
  • goal_type_id: FK to GoalType (kind)
  • goal_category_id: FK to GoalCategory
  • target_date: ISO date string
  • days_to_update: Integer (default 14)
  • image_url: ActiveStorage URL (GCS in production, disk in development)
  • private: Boolean (default true)
  • parent_goal_id: FK for milestone nesting (null for top-level goals)
  • public_id: Auto-generated URL-safe token (never expose integer IDs)
  • Recurrence fields: see habits.md

Pre-fill from Onboarding (variant_c)

Users in the variant_c experiment arrive at GoalCreate.vue from the Goal Builder with answers pre-filled as query parameters:

Query paramMaps toNotes
nameform.nameGoal title from GoalNameSlide
whyform.contentMotivation from GoalWhySlide
targetDateform.targetDateISO date from GoalShapeSlide (Sprint/Arc only)
shapeselectedKindIdResolved to the matching GoalKind by name (case-insensitive)
categorygoalCategoryIdLife area ID from LifeAreaSlide

Pre-fill runs on mount. Users can edit all fields before creating the goal. The form behavior and submission path are unchanged for variant_c.

Quick-add from Suggested Practices

Dashboard's empty-state route surfaces a set of suggested-practice tiles (e.g., "A 10-minute morning walk", "Two pages of writing"). Tapping a tile navigates to GoalCreate.vue with three URL query params that pre-fill the form, enabling rapid goal creation without typing:

Query paramMaps toBehaviorNotes
titleform.nameTrimmed and set on mounte.g., ?title=Meditate
kind=habitselectedKindIdResolved case-insensitively against goalKinds; runs in a watcher that survives async query resolutionOnly habit is supported; other kinds are ignored
recurrenceform.recurrenceTypeMust be one of: daily, weekly, custom_days, interval; invalid values are silently ignorede.g., recurrence=daily

Form behavior:

  • ?title=X alone → stays in Quick Mode (just the name is prefilled, user can save immediately)
  • ?kind=habit or ?recurrence=daily present → flips quickMode = false to display the Advanced Options section so the user can see and edit the prefilled values
  • All params are best-effort: missing or invalid params fall through silently (no errors, no toasts)
  • The form never clobbers non-empty state — if form.name is already set (e.g., by earlier prefill logic), it is not overwritten

URL contract:

/goals/create?title=<goal-name>&kind=habit&recurrence=daily

This is the canonical supported shape for suggested-practice quick-add. Users can also manually construct URLs with a subset of params (e.g., ?title=Learn%20Spanish for title-only prefill).

Creating a Goal from a Template

Template-based creation is documented in full at Goal Templates — Curated Library.


Managing Goals

Goal Detail Page

Navigate to any goal to see its detail page with these sections:

SectionDescription
HeaderBack button, title, category/type badges, public/private indicator
Hero ImageCover image with gradient overlay (if set)
Enhancement BannerSuggests adding details if missing 2+ of: category, type, image, milestones
Progress CardProgress bar (%) or "Not tracked" when the goal has no progress signal (see Progress Tracking), description, target date, days remaining
Habit SectionStreak, calendar, check-in button (only for habits — see habits.md)
InsightContextual coaching based on goal status and progress
EncouragementsAvatar grid + count (public goals)
TimelineCombined events + completed milestones sorted by date

Available Actions (FAB Menu)

  1. Add Update — Opens GoalEventForm modal
  2. Edit Goal — Opens goal form in edit mode
  3. View Public — Routes to /public_goal/{id} (only if not private)
  4. Toggle Privacy — Flip between public and private
  5. Complete Goal — Marks goal as done with confetti animation
  6. Delete Goal — Soft-deletes (confirmation required)

Progress Tracking

"Progress" is one number per goal, derived by a single shared precedence — evaluated in this order, first match wins — so the goal card, the detail hero, and the roadmap bar all agree on the same goal:

  1. Habit — if the goal has a recurrenceType (it's a habit), progress is completionRate. A habit with no observed completion rate yet (completionRate: null) is not tracked, not 0%.
  2. Roadmap — if the goal has milestones, progress is the milestone-completion percentage (completed / total) — the same math the roadmap bar uses.
  3. Target amount — if targetAmount is set and greater than 0, progress is (currentAmount / targetAmount) * 100, capped at 100%. Each goal event increments currentAmount.
  4. Not tracked — otherwise (no target amount, no milestones, not a habit), the goal has no progress number to show.

A not-tracked goal never renders 0%, —%, or N/A — the card and hero show "Not tracked" (or "Progress not tracked" on the detail hero) instead, per docs/ui-specs/honest-goal-progress.md. This is a deliberate semantic change for roadmap goals: progress used to mean currentAmount / targetAmount everywhere, with a goal that had milestones but no target amount fabricating 0%; it now means milestone completion for those goals, matching what the roadmap bar already showed.

The precedence is implemented once on each side — ionic_frontend/src/composables/useGoalProgress.ts (frontend) and rails_api/app/services/goal_progress_calculator.rb (backend) — so no view derives the card/hero ratio inline. Previously four surfaces (the dashboard card, the goals-list card, the detail hero, and the AI Coach's context) each computed targetAmount || 100 independently, which meant an untargeted goal read a confident, fabricated 0% even while its roadmap bar read 100%. See OBJ-1978.

GoalAtAGlance's "Progress" tile is a different, narrower metric — not the precedence above. It shows the raw currentAmount / targetAmount quantity (e.g. "12 / 52 books") and reads "Not tracked" only when targetAmount is unset — it is not wired to useGoalProgress, so it does not pick up the habit or roadmap branches. On a goal whose progress comes from a habit's completionRate or a roadmap's milestone percentage rather than a target amount, this tile can read "Not tracked" while the card, hero, and roadmap bar show a real percentage for the same goal (ionic_frontend/src/views/Goal.vue:242-243 passes goal.targetAmount/goal.currentAmount straight through, not useGoalProgress's output). This is a known gap as of this writing, not a documented design decision — see the OBJ-1978 issue thread.

Goals display a status indicator (GoalStatusPill.vue, driven by Goal#computed_statusgoal.rb:179-197), independent of progress:

  • Active — the default state; self-evident, no explanation needed
  • Needs attention — the target date has passed, or there's been no activity for 2× the goal's check-in cadence (days_to_update)
  • Paused — no activity for 30+ days on a goal that's existed for 30+ days
  • Completed — the goal is marked done

Renamed from On Track/Needs Attention in v4.47 wave 1 (OBJ-3018) — the old "On Track" collided with the identical string PaceIndicator renders for a completely different computation (see Pace Indicator, which still says Ahead / On Track / Behind — that one is unchanged). The Needs attention count on the Goals List Page below carries an explainer bubble: "Active goals with no recent update, or a target date that's passed."


Milestones

Milestones are child goals nested under a parent goal. They have their own completion tracking and events.

Adding Milestones

  1. In the goal form, scroll to the Milestones section
  2. Type a milestone name and press Enter or tap the add button
  3. Milestones appear as a checklist

AI Milestone Generation

  1. Enter a goal name (minimum 3 characters)
  2. Tap Auto-Generate button
  3. The AI generates 3–5 milestone suggestions based on the goal name and context
  4. Uses GENERATE_MILESTONES_MUTATION with goalName and goalContext params

Completing Milestones

  • Toggle the checkbox on a milestone to mark it complete
  • Completed milestones appear in the goal timeline with a timestamp
  • Completing a milestone increments the parent goal's progress

Goal Events (Progress Updates)

Logging an Update

  1. From the goal detail page, tap Add Update (or use the FAB)
  2. Fill in the GoalEventForm:
FieldTypeMax LengthRequired
GoalSelectYes (auto-selected if from goal detail)
ContentTextarea (auto-grow)500 charsYes
ImageUploadNo
MoodSelector (6 emojis)No
  1. Tap Post Update to save

What Happens

  1. A GoalEvent record is created
  2. currentAmount on the goal increments
  3. If an image is uploaded, a Media record is created
  4. Followers are notified via feed items
  5. The event appears in the goal's timeline

Goal Completion

  1. From the goal detail FAB, tap Complete Goal
  2. The Completion Sheet (GoalCompletionSheet.vue) opens: "Finish line." + an optional "What did you take away?" reflection field (max 200 chars)
  3. Tap Save & mark done (with text) or Mark it done (blank) to complete immediately, or Skip for now (only shown once text is entered) to complete without saving a reflection
  4. On confirm: completed is set to true, completedAtTime is recorded, and completionReflection is set from the sheet
  5. Confetti animation plays
  6. Goal moves from Active to Completed tab on the Goals page
  7. Gamification checks trigger (e.g., complete_first_goal, complete_fifth_goal badges)

Completion Reflection

  • Field: completion_reflection — optional free-text, max 200 chars, set only when a goal is marked complete via GoalCompletionSheet.vue.
  • Wire shape: the updateGoal mutation always sends completionReflection when the sheet ran — as the trimmed text, or explicit null on blank/skip (never omitted). This is deliberate: an explicit null clears any stale reflection from a prior completion of the same goal (e.g. re-completing after being marked incomplete). See Nullable Field: Clear vs. Omit Gotcha.
  • Display: shown on the completed goal's detail card (GoalDetailsCard.vue) below the completion badge.
  • Coach personalization: surfaced back to the user later — see Coach personalization below.

Goals List Page

Layout

  • Stats Cards: Total Goals, Avg Progress (reads "Not tracked" when no active goal has a progress signal — averages exclude not-tracked goals rather than counting them as 0; both states carry an explainer bubble: "Averaged across your active goals that can be measured — habits, roadmaps, or target amounts. 'Not tracked' means none of them can be, yet."), Completed, Needs attention (bubble: "Active goals with no recent update, or a target date that's passed.")
  • Insight Card: Contextual advice
  • Tab Bar: Active (with count) | Completed (with count)
  • Filters: All | Habits | Goals type filter
  • Sort: Recent, Progress (not-tracked goals sort as their own group, after all tracked goals — never interleaved as if they were 0%), Name (A-Z), Deadline
  • Search: Live filter on title + category

View

  • Grid layout: 1 col mobile, 2 cols tablet, 3 cols desktop
  • Each card shows: title, category, progress % (or "Not tracked"), status badge, last check-in, days remaining
  • Completed goals render at 75% opacity

Empty States

  • Active tab: "No active goals" + "Create Your First Goal" CTA
  • Completed tab: "No completed goals yet"

Public Goals

Goals can be made public for social accountability. Public goals are accessible at /public_goal/:goalId without authentication.

What's Visible

  • Goal name, description, category, cover image
  • Owner name and avatar
  • Progress bar and percentage
  • Timeline of events (public ones only)
  • Encouragement count

Public Actions

  • Encourage (heart) — logged-in users can encourage the goal
  • Share — native share sheet or copy URL
  • Encourage Events — toggle encouragement on individual timeline events

Collective Goals (Teams)

A personal goal can contribute toward a team-wide shared goal (CollectiveGoal) — a Teams V1 feature, gated behind the teams_collective_goals flag (fail-closed). Full data model, GraphQL surface, and product spec live in the Phase 7 Teams Hub; this section covers what shows up on the personal-goal surface.

  • Opt in — from TeamHomeView, a member links one of their own goals to the team's collective goal (optIntoCollectiveGoal). Only one active contribution per collective goal per user — opting in with a second personal goal while already actively contributing a different one is rejected (opt out first).
  • Privacy contract — the first time a member opts in, TeamPrivacyContractView explains what teammates can and can't see about their contribution. Acknowledgment is a one-time, server-side UserAction record (never localStorage), so it won't re-show on a later visit or a different device.
  • Contribution pill — a linked personal goal's detail page shows a "Contributing to {team name}" pill (only the team name and collective goal title are shared — the reverse direction, i.e. the personal goal's own name, is never exposed to the team).
  • Opt out — self-service, from the same team surface. Opting out preserves contribution history (it never deletes the record), so opting back in later — to the same personal goal — picks up where it left off.
  • Aggregation — only check-ins on the linked goal count toward the collective goal's progress today; check-ins increment the same way habit check-ins always have, with no extra step from the user.

See GraphQL Reference § Collective Goals (Teams V1) for the mutation contracts and Data Models § Collective Goal Contribution for the schema.


UI Components

GoalGridCard

  • Colored header bar (hash-derived from goal name)
  • Category and type badges
  • Progress bar with percentage, or "Not tracked" (see Progress Tracking) — never a fabricated 0%
  • Status indicator (on-track/needs-attention/at-risk)
  • Last check-in relative time
  • Days remaining (or "Overdue")

GoalProgress

  • Circular progress ring with percentage
  • Color-coded by progress threshold
  • Days remaining countdown
  • Target date display

GoalTimeline

  • Vertical timeline connector
  • Event cards: date, content, image, encouragement count, comment count
  • Completed milestones interleaved chronologically

MilestoneManager

  • Add input with enter/button submission
  • Checkbox list with edit/delete actions
  • Counter showing total milestones

Testing the Feature

Manual Testing Checklist

1. Create a Goal (Quick Mode)

  • [ ] Tap + from Dashboard
  • [ ] Enter name: "Test Goal Quick"
  • [ ] Tap Quick Create
  • [ ] Verify goal appears on Dashboard
  • [ ] Verify goal appears on Goals page (Active tab)

2. Create a Goal (Full Mode)

  • [ ] Tap + from Goals page
  • [ ] Enter name: "Test Goal Full"
  • [ ] Tap Expand Details
  • [ ] Select a category and type
  • [ ] Set a target date
  • [ ] Upload a cover image
  • [ ] Add 2 milestones manually
  • [ ] Submit → verify all fields saved correctly
  • [ ] Navigate to goal detail → verify cover image, category, type, milestones all display

3. AI Milestone Generation

  • [ ] Create a goal with name "Learn Spanish"
  • [ ] Tap Auto-Generate milestones
  • [ ] Verify 3–5 milestones are suggested
  • [ ] Verify milestones appear in the milestone list

4. Log a Goal Event

  • [ ] Navigate to a goal detail page
  • [ ] Tap Add Update
  • [ ] Enter content: "Made progress today"
  • [ ] Upload an image
  • [ ] Select a mood
  • [ ] Submit → verify event appears in timeline
  • [ ] Verify image displays in the event card
  • [ ] Verify character counter shows at bottom-right of textarea

5. Complete a Goal

  • [ ] Navigate to a goal detail page
  • [ ] Tap FAB → Complete Goal
  • [ ] Confirm in dialog
  • [ ] Verify confetti animation plays
  • [ ] Verify goal moves to Completed tab on Goals page
  • [ ] Verify goal shows "Completed" badge on detail page

6. Goals List Filtering

  • [ ] Navigate to Goals page
  • [ ] Verify stats cards show correct counts
  • [ ] Toggle between Active and Completed tabs
  • [ ] Filter by type: All, Habits, Goals
  • [ ] Sort by: Recent, Progress, Name, Deadline
  • [ ] Use search bar to filter by name
  • [ ] Verify empty states show when no results

7. Public Goal

  • [ ] Create a goal and toggle privacy to Public
  • [ ] Copy the public URL
  • [ ] Open in incognito → verify goal is accessible
  • [ ] Sign in and encourage the public goal
  • [ ] Verify encouragement count increments

8. Goal Deletion

  • [ ] Navigate to a goal detail page
  • [ ] Tap FAB → Delete Goal
  • [ ] Confirm deletion
  • [ ] Verify goal disappears from Goals list
  • [ ] Verify it's a soft-delete (record has deleted_at timestamp)

9. Milestones

  • [ ] Add milestones to an existing goal
  • [ ] Toggle a milestone as complete
  • [ ] Verify completed milestone appears in timeline
  • [ ] Delete a milestone
  • [ ] Verify parent goal progress updates

10. Create Goal from Template (Phase 84)

  • [ ] Navigate to Goal Create
  • [ ] Verify "Start from a template" CTA banner appears in Quick Mode
  • [ ] Tap the banner → Template Picker modal opens with themes and template cards
  • [ ] Verify template cards show cover image/gradient, duration, and milestone count
  • [ ] Tap a template card → detail view slides in with full description and milestone list
  • [ ] Tap "Show all" → milestone list expands to show all milestones
  • [ ] Tap "Use this template" → form returns to Quick Mode with template chip shown
  • [ ] Verify template chip displays template name with "Change" option
  • [ ] Enter a goal name and tap "Create Goal" → goal saves with milestones pre-filled
  • [ ] Navigate to the created goal's detail page → verify "From template: [name]" badge appears below title
  • [ ] Test switching templates: tap "Change" on the template chip, select a different template
  • [ ] Verify milestone list updates to the new template's milestones
  • [ ] Test error state: close app mid-load, reopen template picker → verify error message with "Try again" button
  • [ ] Test empty state: if no templates exist (unlikely), verify "No templates yet" message

11. Enhancement Banner

  • [ ] Create a goal with only a name (Quick Mode)
  • [ ] Navigate to goal detail
  • [ ] Verify Enhancement Banner appears suggesting adding category, type, image, milestones
  • [ ] Add missing details → verify banner disappears

Smoke Tests

Automated smoke tests covering goals:

  • ionic_frontend/tests/smoke-playwright/specs/goals-habits/dashboard-goal-create.spec.ts — Creates a goal via the form
  • ionic_frontend/tests/smoke-playwright/specs/goals-habits/ — Goal progress and completion covered in goals-habits suite (no standalone equivalents)

Run locally:

bash
cd ionic_frontend
npm run smoke:playwright:staging

Backend Unit Tests

bash
cd rails_api

# Model specs
bundle exec rspec spec/models/goal_spec.rb
bundle exec rspec spec/models/goal_event_spec.rb
bundle exec rspec spec/models/milestone_spec.rb

# Interaction specs
bundle exec rspec spec/interactions/goal_tracking/add_goal_spec.rb
bundle exec rspec spec/interactions/goal_tracking/update_goal_spec.rb
bundle exec rspec spec/interactions/goal_tracking/add_goal_event_spec.rb

# GraphQL mutation specs
bundle exec rspec spec/requests/mutations/add_goal_spec.rb
bundle exec rspec spec/requests/mutations/update_goal_spec.rb
bundle exec rspec spec/requests/mutations/add_goal_event_spec.rb
bundle exec rspec spec/requests/mutations/ai/generate_milestones_spec.rb

Frontend Unit Tests

bash
cd ionic_frontend

npm run test:unit -- --run -t "GoalCreate"
npm run test:unit -- --run -t "GoalGridCard"
npm run test:unit -- --run -t "GoalTimeline"
npm run test:unit -- --run -t "MilestoneManager"
npm run test:unit -- --run -t "GoalEventForm"
npm run test:unit -- --run -t "useGoalForm"

GraphQL API Reference

Queries

graphql
query GoalsQuery {
  goals {
    publicId
    name
    content
    currentAmount
    targetAmount
    targetDateTime
    completed
    private
    updatedAtTime
    daysToUpdate
    imageUrl
    recurrenceType
    recurrenceDays
    recurrenceInterval
    habitStreak
    checkedInToday
    dueToday
    completionRate
    kind { id name }
    category { id name }
    allEvents { publicId content createdAtTime media { imageUrl } }
    milestones { publicId name completed completedAtTime }
    encouragements { id }
    comments { id content }
  }
}

query GoalQuery($id: ID!) {
  goal(id: $id) {
    publicId
    name
    content
    targetDateTime
    daysToUpdate
    completed
    completedAtTime
    parentGoalId
    createdAtTime
    updatedAtTime
    imageUrl
    private
    fromTemplateName
    kind { id name }
    category { id name }
    allEvents {
      publicId
      content
      createdAtTime
      milestoneName
      media { imageUrl }
      encouragements { id user { publicId firstName lastName } }
      comments { id content createdAtTime user { publicId firstName lastName } }
    }
    milestones { publicId name completed completedAtTime allEvents { publicId content createdAtTime } }
    encouragements { id user { publicId firstName lastName photo { imageUrl } } }
  }
}

query GoalKindsQuery {
  goalKinds { id name description displayNumber }
}

query GoalCategoriesQuery {
  goalCategories { id name }
}

query GoalTemplatesQuery {
  goalTemplates {
    publicId
    name
    description
    theme
    estimatedDurationDays
    imageUrl
    category { id name }
    milestones {
      name
      order
      daysOffsetFromStart
    }
  }
}

Mutations

graphql
mutation AddGoal(
  $name: String!
  $content: String
  $identityPrompt: String
  $pastAttemptContext: String
  $typeId: ID
  $categoryId: ID
  $targetDate: String
  $daysToUpdate: Int
  $parentGoalId: ID
  $imageUrl: String
  $recurrenceType: String
  $recurrenceDays: [String!]
  $recurrenceInterval: Int
  $fromTemplateId: ID
) {
  addGoal(
    name: $name, content: $content,
    identityPrompt: $identityPrompt, pastAttemptContext: $pastAttemptContext,
    goalTypeId: $typeId, goalCategoryId: $categoryId,
    targetDate: $targetDate, daysToUpdate: $daysToUpdate,
    parentGoalId: $parentGoalId, imageUrl: $imageUrl,
    recurrenceType: $recurrenceType, recurrenceDays: $recurrenceDays,
    recurrenceInterval: $recurrenceInterval, fromTemplateId: $fromTemplateId
  ) {
    goal { publicId name fromTemplateName }
    errors
  }
}

mutation UpdateGoal(
  $goalId: ID!, $name: String, $content: String,
  $identityPrompt: String, $pastAttemptContext: String, $completionReflection: String,
  $typeId: ID, $categoryId: ID, $targetDate: String, $daysToUpdate: Int,
  $completed: Boolean, $deleted: Boolean, $private: Boolean, $imageUrl: String
) {
  updateGoal(
    goalId: $goalId, name: $name, content: $content,
    identityPrompt: $identityPrompt, pastAttemptContext: $pastAttemptContext,
    completionReflection: $completionReflection,
    goalTypeId: $typeId, goalCategoryId: $categoryId,
    targetDate: $targetDate, daysToUpdate: $daysToUpdate,
    completed: $completed, deleted: $deleted, private: $private, imageUrl: $imageUrl
  ) {
    goal { publicId name completed private }
    errors
  }
}

mutation AddGoalEvent(
  $goalId: ID!
  $content: String!
  $imageUrl: String
  $mood: String
  $clientTimestamp: String
  $clientEventId: String
  $file: Upload
) {
  addGoalEvent(goalId: $goalId, content: $content, imageUrl: $imageUrl, mood: $mood, clientTimestamp: $clientTimestamp, clientEventId: $clientEventId, file: $file) {
    goalEvent {
      publicId content createdAtTime
      media { imageUrl }
      encouragements { id }
      comments { id }
    }
    errors
  }
}

mutation GenerateMilestones($goalName: String!, $goalContext: String) {
  generateMilestones(goalName: $goalName, goalContext: $goalContext) {
    milestones
  }
}

Offline-first check-ins

Users can check in to goals and habits even without internet connectivity. The app queues mutations locally and syncs them when connectivity returns. Full contract (what's queued and why, persistence boundary, staleness, failure handling): docs/architecture/offline-contract.md. This section covers the goal/habit-specific mechanics; it doesn't restate the general contract.

Client-side behavior

Optimistic UI: When a user completes a check-in (habit or goal event), the UI updates immediately with a completed state, regardless of network connectivity.

Offline queue: If the GraphQL mutation fails due to a network error, the check-in is added to a persistent sync queue stored in SecureStorage (sync_queue_v1). The user sees a toast: "Saved offline. Syncing later."

Automatic sync: When the device detects a return to connectivity, App.vue's watchEffect (driven by useNetworkStatus()) automatically drains the sync queue, replaying mutations in FIFO order — no user action required.

Queue details:

  • Stored in SecureStorage (sync_queue_v1) — survives app kill and restart
  • Maximum queue length: 50 items
  • Maximum retry attempts: 5 per item
  • On exhaustion (5 failed attempts), the item is never silently dropped. It moves to a separate failed list (sync_failed_v1, syncStore.ts's toFailedMutation), a toast tells the user ("N check-ins didn't send.") with a "Review" action, and DidntSendSheet lets them retry or explicitly discard it — discard(id) is the only path that actually deletes a payload. The same applies to a check-in the server rejects outright (a real errors response, e.g. an out-of-window completedDate) — it fails the same way, not a silent no-op. A queue overflow (>50 items) drops the oldest pending item the same way, with a Sentry signal.

Implementation: Pinia syncStore (ionic_frontend/src/stores/syncStore.ts) with useOfflineMutation composable (ionic_frontend/src/composables/useOfflineMutation.ts) for easy integration into components.

Limitations

Image/file mutations not queued: addGoalEvent mutations with image or file attachments are not queued offline — they degrade with a clear error toast if sent without connectivity. Full Blob serialization via IndexedDB is a future enhancement.

Timestamp semantics: The addGoalEvent mutation accepts a client_timestamp (ISO 8601) and client_event_id (UUID) from the client. This ensures that if the device was offline for hours, the check-in is logged at the time it occurred locally, not at the time the server received it. The backend uses client_timestamp for streak coalescing and client_event_id for idempotency (duplicate submissions return the original event).

The checkInHabit mutation accepts goal_id: ID!, an optional note: String (a one-sentence journal note, max 280 characters, ignored server-side unless micro_journal_enabled is on for the current user), an optional completed_date: String (ISO date, for crediting an offline check-in to the day it was actually tapped rather than the day the queued mutation replays — see Widening the check-in window below), and an optional client_event_id: String (a client-generated UUID). client_event_id is the idempotency mechanism, not server-side date-scoped logic alone: GoalTracking::CheckInHabit#existing_completion checks client_event_id first (a replay with a different completed_date still returns the original completion), falling back to a completed_date-scoped lookup only when no client_event_id was sent (rails_api/app/interactions/goal_tracking/check_in_habit.rb:47-54).

Widening the check-in window

completed_date must fall within the last 7 days in the user's timezone (widened from the original next-day-only window in v4.58 Phase 1, PR #3163, OBJ-3740) — a rejected date returns errors: ["completedDate must be within the last 7 days in the user's timezone"], which the sync queue treats as a permanent failure per the exhaustion behavior above, not a silent drop. useOfflineMutation.ts derives completedDate from the local calendar day at the moment the user tapped (not the day the mutation eventually replays), so a multi-day offline session still credits each check-in to the right day. See rails_api/app/graphql/mutations/check_in_habit.rb:73-91 for the exact bound and rationale.


Coach Personalization: Identity, Past Attempt & Completion Reflection

Three optional short-text fields — identity_prompt (goal creation), past_attempt_context (goal creation), completion_reflection (goal completion) — thread into Coach's personalization pipeline (Ai::CoachService). All three are opt-in, free, and never required; the fidelity contract locked at planning time forbids promoting any of them to a required field or gating a feature on their presence.

  • What Coach does with them: each value is folded into the free-text context Coach assembles before calling the LLM (Ai::BuildCoachContext for chat advice, Ai::Prompts::Synopsis for the daily synopsis, Ai::CoachService#generate_welcome_back for the lapse/streak-lapse re-engagement message). Coach never asks the user about these fields directly — it only references them if the user already wrote them.
  • Instrumentation is presence-only: every LLM call metadata payload records identity_prompt_present: true|false (and the equivalent for the other two fields) — never the raw text — so product analytics can measure adoption without persisting user free-text in observability tooling.
  • Prompt-injection boundary: user-authored text is untrusted input to the LLM. The single-turn advice path (CoachService#get_advice via build_messages/fence_user_content) and Ai::Prompts::Synopsis wrap it in an explicit <user_input>...</user_input> fence with an instruction not to follow directives inside it. The multi-turn chat path is intentionally left unfenced per-turn — it already has a role-separated system/user message boundary plus MULTI_TURN_INJECTION_GUARD, so re-fencing would be redundant, not safer.
  • Completion reflection re-engagement: when a user comes back after a lapse, WelcomeBackOfferResolver surfaces the reflection from their most recently completed goal (a different goal from the one they lapsed on) via Ai::CoachService#generate_welcome_back's streak-lapse pathway — "Reflection from last completed goal: …" in the assembled context, if one exists.
  • Full backend surface + prompt template reference: see Coach Surfaces — Comprehensive Audit for the complete method-by-method breakdown (get_advice, generate_synopsis, generate_welcome_back, generate_check_in_prompt) and prompt template versions.

Known Behaviors & Edge Cases

  1. Quick Mode vs Full Mode: Quick Mode creates a goal with just name and description. Full Mode exposes all fields. Both call the same ADD_GOAL_MUTATION — Quick Mode simply omits optional fields.

  2. Private by default: All new goals are private. The AddGoal interaction sets private: true unless explicitly overridden.

  3. Soft deletion: Deleting a goal sets deleted_at via acts_as_paranoid. The goal is excluded from normal queries but can be recovered with with_deleted scope.

  4. Target date default: If no target date is set, the form defaults to 14 days from now. This can be changed in full mode.

  5. Days to update: This field controls reminder notifications. If a goal hasn't been updated in N days, it triggers a past_due notification. Default is 14 days.

  6. Milestone nesting: Milestones are stored as child goals with parent_goal_id set. They share the same Goal model but are filtered by the parent relationship.

  7. Image handling: Cover images are uploaded via ActiveStorage (Google Cloud Storage in production, disk in development). The first event's media is also used as the goal's image_url display fallback.

  8. Progress calculation: see Progress Tracking for the full habit → roadmap → target-amount → not-tracked precedence. currentAmount increments by 1 with each goal event; the target-amount branch is (currentAmount / targetAmount) * 100, capped at 100%.

  9. Goal completion triggers gamification: Completing a goal triggers badge checks via GamificationServicecomplete_first_goal, complete_fifth_goal, complete_tenth_goal.

  10. Public goal access: Public goals at /public_goal/:id are accessible without authentication. The PUBLIC_GOAL_QUERY returns limited fields (no private data).

  11. Identity/past-attempt/completion-reflection are optional at every layer (v3.12): no migration backfill, so pre-v3.12 goals read nil for all three. None of them gate a feature, block goal creation/completion, or trigger an engagement nudge when empty — omitting them is a fully supported, unremarkable path.

  12. Explicit-clear vs. omit on updateGoal: identityPrompt, pastAttemptContext, and completionReflection are the three fields where sending an explicit GraphQL null clears the stored value, while omitting the argument entirely leaves it untouched — every other nullable updateGoal argument only supports omit-to-preserve. See Nullable Field: Clear vs. Omit Gotcha.

Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745: corrected the offline sync queue's exhaustion behavior — failed items are never silently dropped, they move to a reviewable failed list — and documented client_event_id idempotency, completed_date, and the 7-day check-in window against shipped source)

Loading…