Goals — Feature & Testing Guide
Related docs:
- Goal Roadmap PRD — original product rationale and design exploration
- Stock Photos for Goals & Communities PRD — original product rationale and design exploration
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
- Tap + (create goal) from the Dashboard or Goals page
- Enter a name (required) and optional description
- In Quick Mode, tap Create Goal to save immediately
- Or tap Expand Details to reveal full configuration:
| Field | Type | Required | Description |
|---|---|---|---|
| Name | Text | Yes | Goal title |
| Description | Textarea | No | Motivation or context |
| Identity Prompt ("Who you're becoming") | Textarea, max 240 chars | No | Free-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 toggle | No | Free-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 Type | Selection | No | Fetched via GOAL_KINDS_QUERY (e.g., Habit, Milestone, Quantity) |
| Category | Button Group | No | Fitness, Learning, etc. (GOAL_CATEGORIES_QUERY) |
| Target Date | Date Picker | No | Defaults to 14 days from now |
| Days to Update | Number (1–365) | No | How often you should log progress (default: 14) |
| Cover Image | Image Upload | No | Hero image via ActiveStorage (GCS in production) |
| Recurrence | Button Group | No | Converts goal to habit (see habits.md) |
- Optionally add milestones (sub-goals) via the Milestone Manager
- Optionally tap Auto-Generate to create AI-suggested milestones (requires name > 3 chars)
- Tap Create Goal to save
What Gets Stored
name: Goal title (required)content: Description textidentity_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 GoalCategorytarget_date: ISO date stringdays_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 param | Maps to | Notes |
|---|---|---|
name | form.name | Goal title from GoalNameSlide |
why | form.content | Motivation from GoalWhySlide |
targetDate | form.targetDate | ISO date from GoalShapeSlide (Sprint/Arc only) |
shape | selectedKindId | Resolved to the matching GoalKind by name (case-insensitive) |
category | goalCategoryId | Life 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 param | Maps to | Behavior | Notes |
|---|---|---|---|
title | form.name | Trimmed and set on mount | e.g., ?title=Meditate |
kind=habit | selectedKindId | Resolved case-insensitively against goalKinds; runs in a watcher that survives async query resolution | Only habit is supported; other kinds are ignored |
recurrence | form.recurrenceType | Must be one of: daily, weekly, custom_days, interval; invalid values are silently ignored | e.g., recurrence=daily |
Form behavior:
?title=Xalone → stays in Quick Mode (just the name is prefilled, user can save immediately)?kind=habitor?recurrence=dailypresent → flipsquickMode = falseto 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.nameis already set (e.g., by earlier prefill logic), it is not overwritten
URL contract:
/goals/create?title=<goal-name>&kind=habit&recurrence=dailyThis 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:
| Section | Description |
|---|---|
| Header | Back button, title, category/type badges, public/private indicator |
| Hero Image | Cover image with gradient overlay (if set) |
| Enhancement Banner | Suggests adding details if missing 2+ of: category, type, image, milestones |
| Progress Card | Progress bar (%) or "Not tracked" when the goal has no progress signal (see Progress Tracking), description, target date, days remaining |
| Habit Section | Streak, calendar, check-in button (only for habits — see habits.md) |
| Insight | Contextual coaching based on goal status and progress |
| Encouragements | Avatar grid + count (public goals) |
| Timeline | Combined events + completed milestones sorted by date |
Available Actions (FAB Menu)
- Add Update — Opens GoalEventForm modal
- Edit Goal — Opens goal form in edit mode
- View Public — Routes to
/public_goal/{id}(only if not private) - Toggle Privacy — Flip between public and private
- Complete Goal — Marks goal as done with confetti animation
- 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:
- Habit — if the goal has a
recurrenceType(it's a habit), progress iscompletionRate. A habit with no observed completion rate yet (completionRate: null) is not tracked, not0%. - Roadmap — if the goal has milestones, progress is the milestone-completion percentage (
completed / total) — the same math the roadmap bar uses. - Target amount — if
targetAmountis set and greater than 0, progress is(currentAmount / targetAmount) * 100, capped at 100%. Each goal event incrementscurrentAmount. - 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 rawcurrentAmount / targetAmountquantity (e.g. "12 / 52 books") and reads "Not tracked" only whentargetAmountis unset — it is not wired touseGoalProgress, so it does not pick up the habit or roadmap branches. On a goal whose progress comes from a habit'scompletionRateor 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-243passesgoal.targetAmount/goal.currentAmountstraight through, notuseGoalProgress'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_status — goal.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
- In the goal form, scroll to the Milestones section
- Type a milestone name and press Enter or tap the add button
- Milestones appear as a checklist
AI Milestone Generation
- Enter a goal name (minimum 3 characters)
- Tap Auto-Generate button
- The AI generates 3–5 milestone suggestions based on the goal name and context
- Uses
GENERATE_MILESTONES_MUTATIONwithgoalNameandgoalContextparams
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
- From the goal detail page, tap Add Update (or use the FAB)
- Fill in the GoalEventForm:
| Field | Type | Max Length | Required |
|---|---|---|---|
| Goal | Select | — | Yes (auto-selected if from goal detail) |
| Content | Textarea (auto-grow) | 500 chars | Yes |
| Image | Upload | — | No |
| Mood | Selector (6 emojis) | — | No |
- Tap Post Update to save
What Happens
- A
GoalEventrecord is created currentAmounton the goal increments- If an image is uploaded, a
Mediarecord is created - Followers are notified via feed items
- The event appears in the goal's timeline
Goal Completion
- From the goal detail FAB, tap Complete Goal
- The Completion Sheet (
GoalCompletionSheet.vue) opens: "Finish line." + an optional "What did you take away?" reflection field (max 200 chars) - 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
- On confirm:
completedis set totrue,completedAtTimeis recorded, andcompletionReflectionis set from the sheet - Confetti animation plays
- Goal moves from Active to Completed tab on the Goals page
- Gamification checks trigger (e.g.,
complete_first_goal,complete_fifth_goalbadges)
Completion Reflection
- Field:
completion_reflection— optional free-text, max 200 chars, set only when a goal is marked complete viaGoalCompletionSheet.vue. - Wire shape: the
updateGoalmutation always sendscompletionReflectionwhen the sheet ran — as the trimmed text, or explicitnullon blank/skip (never omitted). This is deliberate: an explicitnullclears 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,
TeamPrivacyContractViewexplains what teammates can and can't see about their contribution. Acknowledgment is a one-time, server-sideUserActionrecord (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_attimestamp)
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 formionic_frontend/tests/smoke-playwright/specs/goals-habits/— Goal progress and completion covered in goals-habits suite (no standalone equivalents)
Run locally:
cd ionic_frontend
npm run smoke:playwright:stagingBackend Unit Tests
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.rbFrontend Unit Tests
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
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
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
failedlist (sync_failed_v1,syncStore.ts'stoFailedMutation), a toast tells the user ("N check-ins didn't send.") with a "Review" action, andDidntSendSheetlets 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 realerrorsresponse, e.g. an out-of-windowcompletedDate) — 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::BuildCoachContextfor chat advice,Ai::Prompts::Synopsisfor the daily synopsis,Ai::CoachService#generate_welcome_backfor 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_adviceviabuild_messages/fence_user_content) andAi::Prompts::Synopsiswrap 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 plusMULTI_TURN_INJECTION_GUARD, so re-fencing would be redundant, not safer. - Completion reflection re-engagement: when a user comes back after a lapse,
WelcomeBackOfferResolversurfaces the reflection from their most recently completed goal (a different goal from the one they lapsed on) viaAi::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
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.Private by default: All new goals are private. The
AddGoalinteraction setsprivate: trueunless explicitly overridden.Soft deletion: Deleting a goal sets
deleted_atviaacts_as_paranoid. The goal is excluded from normal queries but can be recovered withwith_deletedscope.Target date default: If no target date is set, the form defaults to 14 days from now. This can be changed in full mode.
Days to update: This field controls reminder notifications. If a goal hasn't been updated in N days, it triggers a
past_duenotification. Default is 14 days.Milestone nesting: Milestones are stored as child goals with
parent_goal_idset. They share the sameGoalmodel but are filtered by the parent relationship.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_urldisplay fallback.Progress calculation: see Progress Tracking for the full habit → roadmap → target-amount → not-tracked precedence.
currentAmountincrements by 1 with each goal event; the target-amount branch is(currentAmount / targetAmount) * 100, capped at 100%.Goal completion triggers gamification: Completing a goal triggers badge checks via
GamificationService—complete_first_goal,complete_fifth_goal,complete_tenth_goal.Public goal access: Public goals at
/public_goal/:idare accessible without authentication. ThePUBLIC_GOAL_QUERYreturns limited fields (no private data).Identity/past-attempt/completion-reflection are optional at every layer (v3.12): no migration backfill, so pre-v3.12 goals read
nilfor 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.Explicit-clear vs. omit on
updateGoal:identityPrompt,pastAttemptContext, andcompletionReflectionare the three fields where sending an explicit GraphQLnullclears the stored value, while omitting the argument entirely leaves it untouched — every other nullableupdateGoalargument 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)