Habit Tracking — Feature & Testing Guide
Related docs:
- Streak Compassion PRD — original product rationale and design exploration
Overview
Habits are a special mode of Goals. Any goal can become a habit by setting a recurrence type during creation. Habits have streaks, check-ins, completion calendars, and streak freezes — all designed for the Duolingo-style "open, check in, leave" loop.
Creating a Habit
Via the Goal Form
- Tap + (create goal) from the Dashboard or Goals page
- Fill in name, description, category, and optional cover image
- Tap "Expand Details" to reveal goal type options
- Select "Habit" as the goal type
- A "Habit Recurrence" section appears with four pattern options:
| Pattern | Config | When It's Due |
|---|---|---|
| Daily | No extra config | Every day |
| Weekly | Pick one day (e.g., Tuesday) | That day each week |
| Custom Days | Toggle multiple days (M T W T F S S) | Only on selected days |
| Every N Days | Enter a number (1–365) | Every N days after last check-in |
- (Optional) Fill in Time per practice — the minutes you intend to spend each session. Surfaces as
{N min}on the dashboard's Today's practice rows. - Tap Create Goal to save
What Gets Stored
recurrence_type:"daily","weekly","custom_days", or"interval"recurrence_days: Array like["mon", "wed", "fri"](for weekly/custom_days)recurrence_interval: Integer like3(for "every N days" mode)duration_minutes: Optional integer (> 0). Null when the user leaves Time per practice blankhabit_streak: Starts at 0streak_freezes_available: Starts at 0
Key Behaviors
- A goal is considered a habit if
recurrence_typeis present - Weekly and Custom Days both use the
recurrence_daysfield — the difference is UX (weekly defaults to one day, custom allows many) - The "Every N Days" interval counts from the
last_checked_in_date, not from creation date. Iflast_checked_in_dateis nil (never checked in), the habit is always considered due.
Checking In
Where You Can Check In
- Dashboard → Focus Mode card stack — each due habit gets its own full-width
HabitCardin the swipeableStreamlinedDashboardcarousel, with a single Check In button (see Focus Mode: In-Session Achievement Rewards for what happens after check-in during a session) - Dashboard → Full Dashboard → "Today's practice" — a
TodayActionslist widget (reachable via the "Full Dashboard" toggle from Focus Mode), with one row and a one-tap check-in button per due habit - Goal Detail Page — "Check In" button in the habit tracking section
What Happens on Check-In
- A
HabitCompletionrecord is created for today's date - A
GoalEventwith "Checked in!" is created (appears in the timeline) last_checked_in_dateis set to today- The streak is recalculated (see Streak Logic below)
- 25 XP is awarded
- Followers are notified via feed items
- The check-in button changes to a green check icon and becomes disabled
Idempotency
Checking in twice on the same day is safe — the backend returns the existing completion without creating a duplicate (unique index on [goal_id, completed_date]).
"Due Today" Logic
The check-in button and "Today's Habits" widget only show habits that are due today:
- Daily: Always due
- Weekly / Custom Days: Due only if today's day-of-week (e.g.,
"wed") is in therecurrence_daysarray - Interval: Due if
(today - last_checked_in_date) >= recurrence_interval, or if never checked in
Already checked in today? A habit that has already been checked in today is never due, regardless of recurrence type — Goal#due_today? returns false immediately when checked_in_today? is true.
How due_today? is computed internally: Goal#due_today? delegates to a two-step composition: it returns false immediately when checked_in_today? is true, then calls Goal#expected_today? — the recurrence-calendar predicate. expected_today? answers "was this habit scheduled for today?" without regard to check-in state; it returns false for non-habit goals and completed goals. This separation enables the User.requiredCheckinsCompleteToday GraphQL field (see below) to determine whether all of today's scheduled check-ins are complete.
Important: A habit that is NOT due today will not appear in "Today's Habits" and won't show a check-in button on the detail page. This is by design — you only need to act on habits when they're scheduled.
App-Link Check-In Redirect
Habits can optionally link out to another app for the actual work — for streak-driven habits that already live somewhere else (Duolingo, Strava, Chess.com, etc.). One tap opens the linked app and marks today's check-in complete in the same action, no callback: link-out is the completion signal, not a return-trip confirmation.
Setting an app link
The "App link" field lives inside the habit-only block of GoalForm.vue, below the recurrence options:
- Preset picker — a horizontally-scrollable strip of app tiles for common habit-adjacent apps (v1 starter set: Duolingo, Chess.com, Strava, Peloton, MyFitnessPal, Headspace, Calm, Kindle, Yousician, Notion). Tapping a tile fills the field with that app's
https://universal link and marks the tile selected (ring + check badge, label bolds), with a confirm line: "Opens {AppName} when you check in." - Manual paste fallback — "Or paste a link" input below the strip, for any app not in the preset list. Helper copy guides discovery: "Open the app, tap Share, and copy the link." — the user never has to know a raw scheme like
duolingo://exists. - Selection is derived, not stored separately (
form.appLink === preset.url) — editing the input after tapping a preset naturally clears the tile's selected state. - Invalid links validate on blur and show "That link won't open. Use a full https:// or app link."
On-device app discovery isn't possible — why there's a preset list
A device-level "pick from your installed apps" picker was considered and ruled out: iOS sandboxes app enumeration entirely (no public API lists installed apps), and Android 11+ requires the Play-restricted QUERY_ALL_PACKAGES permission for it. Capacitor has no enumeration API either. The curated preset list + guided manual paste is the deliberate discovery answer instead — see ionic_frontend/src/constants/habitAppPresets.ts for the full rationale in-code.
Check-in behavior
When a habit has an appLink, the check-in button changes on both surfaces that support check-in:
- Dashboard Focus Mode
HabitCard— button reads "Open & check in" with an outbound-link icon and a caption "Opens your app, then marks today done." Once checked in, an optional "Open app" button lets the user reopen the target app. The same treatment applies to the micro-journal note-path check-in button when a note is open, not just the collapsed primary button. - Goal Detail
GoalActionBar— same "Open & check in" treatment while the habit is due. - Habits with no
appLinkare unaffected — the plain "Check In" button is byte-for-byte unchanged. - One tap does both: opens the link and fires the existing check-in mutation in the same action.
Redirect mechanism — useHabitLinkOut
ionic_frontend/src/composables/useHabitLinkOut.ts is the single safe-open path for a habit's app link:
- Native (iOS/Android):
@capacitor/app-launcher'sAppLauncher.openUrl({ url })— hands off to the installed app via the system opener, or falls back to the web if the app isn't installed. An unresolvable link is a silent no-op — no error UI. - Web:
window.open(url, '_blank', 'noopener').
@capacitor/browser was deliberately not used for this — it opens an in-app SFSafariViewController/Chrome Custom Tab and never actually hands off to the target app, which defeats the point of a check-in redirect. @capacitor/browser stays reserved for its existing in-app uses (Clerk, Stripe) — unrelated to this feature.
Unsafe URLs are guarded before either opener is called (see scheme validation below). All v1 presets are https:// universal links, so no iOS LSApplicationQueriesSchemes / Android <queries> scheme declarations were needed — AppLauncher.openUrl doesn't require scheme pre-declaration; that's only enforced for the optional canOpenUrl probe, which this feature doesn't call. A future preset backed by a raw custom scheme (rather than a universal link) would need those native declarations added first.
Link scheme validation
Frontend and backend enforce the same deny-set — https:// or any scheme not on the deny-set is accepted (custom app schemes like duolingo:// aren't enumerable in advance, so this is a deny-set rather than an allow-list):
- Backend:
AppLinkValidatable::UNSAFE_APP_LINK_SCHEMES(rails_api/app/models/concerns/app_link_validatable.rb) - Frontend:
isSafeAppLink/UNSAFE_APP_LINK_SCHEMES(ionic_frontend/src/helpers/appLinkScheme.ts), kept in sync with the backend list
Denied schemes: http, ftp, javascript, data, file, vbscript, blob, about, chrome, intent, content, ms-appx, itms-apps.
What gets stored
app_link: nullable string onGoal, max 2048 characters, validated against the scheme deny-set on save.- Clearable via an explicit
nullon update —app_linkis wired intoUpdateGoal'sCLEARABLE_NULLABLE_FIELDSallowlist, the same pattern used byidentity_prompt/past_attempt_context/completion_reflection. See Clearing a nullable field vs. omitting it for why this needs explicit handling — omitting the argument and sendingnullare different client intents that Ruby keyword args collapse by default. - Habit → non-habit un-convert:
app_linkis retained silently (not cleared) if the user later switches a habit back to a non-habit goal type.
v1 scope notes
- The preset list is a frontend constant for v1 (
habitAppPresets.ts) — a backend-configurable list (updatable without an app release) is a known fast-follow, not built yet. - Preset tiles use text-monogram icons (first letter of the app name) as an asset-free fallback; bundled brand icon assets can replace them later.
Streaks
See Streak Motifs for how the streak count itself is rendered — five selectable glyphs (stones, sprout, flame, mountain, waves), free/Supporter split, and render grains.
How Streaks Work
The streak represents consecutive successful check-ins. It's recalculated every time you check in by scanning your HabitCompletion records in reverse chronological order.
Calculation logic (update_habit_streak!):
- Start from today (or yesterday if not checked in today)
- Walk backwards through completion dates
- Each consecutive day extends the streak
- First gap breaks it
Example:
- Today is Friday. You've checked in Mon, Tue, Wed, Thu, Fri → streak = 5
- Today is Friday. You checked in Mon, Tue, Thu, Fri (missed Wed) → streak = 2 (Thu + Fri only)
Streak Freezes
Every 7-day streak milestone earns one freeze token:
- 7-day streak → 1 freeze
- 14-day streak → 2 freezes total
- 21-day streak → 3 freezes total
Formula: available = (streak / 7) - already_used
Using a freeze:
- Proactive only — pick a day today through 14 days out that you know you'll miss, before it happens. A past date is rejected (
GoalTracking::UseStreakFreeze#missed_date_in_window?); recovering a streak that already broke is Streak Repair's job, not a freeze's. - The freeze creates a
HabitCompletionwithstreak_freeze_used: true - The frozen day does not count toward your streak count but does not break the streak chain
- On the calendar, frozen days appear in blue (vs. green for real check-ins)
Streak Repair and Streak Insurance (recovering a broken streak)
A freeze only covers a day you see coming. Once a streak actually breaks, ProcessHabitStreaksJob opens a 48-hour streak_repair_eligible_until window (rails_api/app/jobs/gamification/process_habit_streaks_job.rb:29), and two independent lanes can restore it inside that window — both backfill the missed day(s) the same way a freeze does (GoalTracking::UseStreakFreeze(system_granted: true)), just triggered retroactively instead of proactively:
- Streak Repair — free for every user, once per goal lifetime (
goal.streak_repaired_count.zero?), costs XP:min(100 + 5 × pre_break_habit_streak, 500)(Gamification::RepairStreak; see Game Balance § Streak Repair XP Cost). - Streak Insurance (v4.67) — a Supporter-only perk, no XP cost, one claim per calendar month across all your habits (
Gamification::ClaimStreakInsurance). It deliberately never touchesstreak_repaired_count, so claiming it never spends a habit's free Streak Repair — the two lanes stay independent per goal (a Supporter can use both, on different breaks). See Supporter Tier § Streak Insurance for eligibility and the monthly allowance.
Both lanes only work inside the repair window — once it closes, neither is available and the streak stays reset. Gated by the streak_insurance_enabled feature flag; see Feature Flags Reference.
Daily Streak Processing
A background job (ProcessHabitStreaksJob) runs daily and:
- Finds all active habits where
last_checked_in_date< yesterday - Resets their
habit_streakto 0 - Sends a notification that the streak was broken
Note: The job only breaks streaks — it doesn't apply freezes automatically. The user must manually choose to use a freeze before the job runs.
UI Components
Dashboard — habit check-in surfaces
The Dashboard defaults to Focus Mode, a single-card, swipeable flow (StreamlinedDashboard.vue) rather than a multi-habit list. Each due, not-yet-checked-in habit is rendered as its own HabitCard in the card stack, alongside a greeting card, mood card, urgent-goal card, Coach insight, social/ally cards, milestone-celebration cards, and a final "done" card. Each HabitCard shows:
- Header: "Daily Habit" label + streak icon and day count
- A completion ring for that single habit (empty until checked in, filled with a checkmark after)
- The habit name
- A Check In button, which becomes "Done for today" once tapped (900ms success animation, then auto-advances to the next card)
Users can leave Focus Mode via "Full Dashboard" (top toggle on desktop, a link on mobile). The Full Dashboard renders a "Today's practice" list widget (TodayActions.vue) instead — this is the closest surviving analog to the old multi-habit widget:
- Header shows "Today's practice" with a check count (e.g., "2/5 DONE")
- Each row shows:
- Completion Ring — circular progress from the habit's
completionRatefield - Habit name, plus a meta line combining streak day count, duration (if set), and category
- Check-in button — circular button that fills solid green once checked in
- Completion Ring — circular progress from the habit's
- No per-habit streak-freeze indicator is rendered on either Dashboard surface; freeze status and the spend-a-freeze control live only on the Goal Detail page, in
StreakFreezeCard(see below)
Goal Detail — Habit Tracking Section
Visible only on goals with a recurrence type:
- Check-in button — appears if due today and not yet checked in; shows "Done today ✓" after
- Three stat boxes:
- Current Streak (🔥 X day(s))
- Best Streak (all-time)
- Available Freezes (❄️ count)
- Habit Calendar — 30-day grid:
- 🟢 Green = checked in
- 🔵 Blue = streak freeze used
- ⬜ Gray = missed
- Ring highlight on today's date
- Legend below the grid
Goals List — Filter
The Goals page has a type filter: All | Habits | Goals
- "Habits" shows only goals where
recurrenceTypeis set - "Goals" shows only goals where
recurrenceTypeis null
Testing the Feature
Manual Testing Checklist
1. Create a Daily Habit
- [ ] Go to Goals → tap +
- [ ] Enter name: "Test Daily Habit"
- [ ] Tap Expand Details
- [ ] Select Habit as goal type
- [ ] Verify "Habit Recurrence" section appears
- [ ] Select Daily
- [ ] Submit → verify goal is created
- [ ] Go to Dashboard → verify it appears in "Today's Habits"
- [ ] Go to Goals → filter to "Habits" → verify it appears
2. Create a Weekly Habit
- [ ] Create a habit with Weekly recurrence
- [ ] Select a day that is NOT today
- [ ] Verify it does NOT appear in "Today's Habits" (not due)
- [ ] Create another with today's day selected
- [ ] Verify it DOES appear in "Today's Habits"
3. Create a Custom Days Habit
- [ ] Create a habit with Custom Days recurrence
- [ ] Toggle Mon, Wed, Fri
- [ ] Verify it appears in "Today's Habits" only on those days
4. Create an Interval Habit
- [ ] Create a habit with Every N Days = 3
- [ ] On first visit (never checked in), it should be due
- [ ] Check in → next due date should be 3 days later
5. Check In (Dashboard)
- [ ] Find a due habit in "Today's Habits"
- [ ] Tap the check-in button (+ icon)
- [ ] Verify button changes to ✓ and turns green
- [ ] Verify streak count increments
- [ ] Verify checked count updates (e.g., "1/3" → "2/3")
- [ ] Tap the ✓ button again → should be disabled (no double check-in)
6. Check In (Goal Detail)
- [ ] Navigate to a due habit's detail page
- [ ] Verify "Check In" button is visible
- [ ] Tap it → verify it changes to "Done today ✓"
- [ ] Verify stats update: streak, completion rate
- [ ] Verify today's date turns green on the calendar
7. Streak Tracking
- [ ] Check in daily for 3+ consecutive days
- [ ] Verify streak count increases each day
- [ ] Verify "Best Streak" updates when current exceeds it
- [ ] Miss a day → verify streak resets to 0 (after ProcessHabitStreaksJob runs or next check-in recalculation)
8. Streak Freezes
- [ ] Build a 7-day streak → verify 1 freeze token appears
- [ ] Build a 14-day streak → verify 2 freezes total
- [ ] Use a freeze on a future day (today through +14) → verify:
- Streak is preserved (not broken)
- Calendar shows blue for the frozen day
- Available freezes decremented
9. Completion Rate
- [ ] Create a daily habit and check in for ~20 of 30 days
- [ ] Verify completion ring shows ~67% with amber color
- [ ] Check in more → verify ring progresses toward green (≥80%)
10. Goal Detail Page
- [ ] Open a habit's detail page
- [ ] Verify the habit tracking section is visible
- [ ] Verify current streak, best streak, and freezes are displayed
- [ ] Verify 30-day calendar shows check-in history
- [ ] Verify calendar legend (green = done, blue = frozen, gray = missed)
Smoke Tests
Automated smoke tests in ionic_frontend/tests/smoke-playwright/specs/goals-habits/habit-check-in.spec.ts:
- Signs in with demo account
- Creates a daily habit via the goal form
- Verifies it appears on the dashboard in "Today's Habits"
- Checks in via the dashboard widget
- Verifies the habit appears on the goal detail page
Run locally:
cd ionic_frontend
npm run smoke:playwright:stagingBackend Unit Tests
cd rails_api
# Model specs — habit methods, due_today?, streaks, completion_rate
bundle exec rspec spec/models/goal_spec.rb
bundle exec rspec spec/models/habit_completion_spec.rb
# Interaction specs — check-in flow, streak freeze
bundle exec rspec spec/interactions/goal_tracking/check_in_habit_spec.rb
bundle exec rspec spec/interactions/goal_tracking/use_streak_freeze_spec.rb
# GraphQL mutation specs
bundle exec rspec spec/requests/mutations/check_in_habit_spec.rb
bundle exec rspec spec/requests/mutations/use_streak_freeze_spec.rb
# Background job
bundle exec rspec spec/jobs/gamification/process_habit_streaks_job_spec.rbFrontend Unit Tests
cd ionic_frontend
# Component tests
npm run test:unit -- --run -t "HabitCard"
npm run test:unit -- --run -t "HabitCompletionRing"
npm run test:unit -- --run -t "HabitCalendar"GraphQL API Reference
Queries
Habit data is returned as part of the standard goal queries:
query GoalsQuery {
goals {
publicId
name
recurrenceType # "daily" | "weekly" | "custom_days" | "interval" | null
recurrenceDays # ["mon", "wed", "fri"] or null
recurrenceInterval # 3 or null
habitStreak # Current consecutive streak
checkedInToday # Boolean
dueToday # Boolean
completionRate # Float (0.0–1.0)
}
}
query GoalQuery($id: ID!) {
goal(id: $id) {
# ... all above fields plus:
longestHabitStreak
streakFreezesAvailable
streakFreezesUsed
lastCheckedInDate
appLink # https:// or custom app scheme, opened on check-in; null if not set
habitCompletions {
completedDate
streakFreezeUsed
createdAt
}
}
}appLink (String, nullable) — external app URL opened on check-in for the one-click redirect. See App-Link Check-In Redirect above.
Mutations
# Check in to a habit (idempotent for same day)
mutation CheckInHabit($goalId: ID!) {
checkInHabit(goalId: $goalId) {
goal { habitStreak checkedInToday completionRate }
goalEvent { publicId content createdAtTime }
errors
}
}
# Use a streak freeze on a missed date
mutation UseStreakFreeze($goalId: ID!, $missedDate: String!) {
useStreakFreeze(goalId: $goalId, missedDate: $missedDate) {
goal { habitStreak streakFreezesAvailable streakFreezesUsed }
errors
}
}
# Set or clear a habit's app link (part of the standard updateGoal mutation)
mutation UpdateGoal($goalId: ID!, $appLink: String) {
updateGoal(goalId: $goalId, appLink: $appLink) {
goal { appLink }
errors
}
}Pass appLink: null explicitly to clear a previously-set link — omitting the argument entirely leaves the stored value untouched (see Clearing a nullable field vs. omitting it).
User signals
query CurrentUserSignals {
currentUser {
# True once every habit expected today has been checked in
# (also true when no habits are scheduled today)
requiredCheckinsCompleteToday
}
}Known Behaviors & Edge Cases
Weekly vs Custom Days: Both use
recurrence_daysidentically. The distinction is purely UX — weekly suggests picking one day, custom days allows multiple.Interval "never checked in": If
last_checked_in_dateis nil, an interval habit is always considered due. After the first check-in, the interval starts counting.Streak calculation is retrospective: The streak is recalculated from completion records on every check-in, not maintained as a running counter. This means correcting data (e.g., adding a freeze) immediately fixes the streak.
Streak freezes don't count as completions: A frozen day keeps the streak chain intact but doesn't increment the streak counter or count toward
completion_rate.ProcessHabitStreaksJob timing: The daily job resets streaks for habits where
last_checked_in_date < yesterday. If a user hasn't checked in today but the job hasn't run yet, the streak is still intact. Once the job runs, it's reset to 0.XP award: Each check-in awards exactly 25 XP via
GamificationService.award_xp. Duplicate check-ins (idempotent) do not award additional XP since no new completion is created.Completed habits: Setting a habit as
completed: trueremoves it from the active habits list and "Today's Habits" widget. Theactive_habitsscope filters oncompleted: false.
Last updated: 2026-09-20 (v4.67 Phase 5, OBJ-4026: added § Streak Repair and Streak Insurance)