Skip to content

Habit Tracking — Feature & Testing Guide

Related docs:

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

  1. Tap + (create goal) from the Dashboard or Goals page
  2. Fill in name, description, category, and optional cover image
  3. Tap "Expand Details" to reveal goal type options
  4. Select "Habit" as the goal type
  5. A "Habit Recurrence" section appears with four pattern options:
PatternConfigWhen It's Due
DailyNo extra configEvery day
WeeklyPick one day (e.g., Tuesday)That day each week
Custom DaysToggle multiple days (M T W T F S S)Only on selected days
Every N DaysEnter a number (1–365)Every N days after last check-in
  1. (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.
  2. 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 like 3 (for "every N days" mode)
  • duration_minutes: Optional integer (> 0). Null when the user leaves Time per practice blank
  • habit_streak: Starts at 0
  • streak_freezes_available: Starts at 0

Key Behaviors

  • A goal is considered a habit if recurrence_type is present
  • Weekly and Custom Days both use the recurrence_days field — 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. If last_checked_in_date is nil (never checked in), the habit is always considered due.

Checking In

Where You Can Check In

  1. Dashboard → Focus Mode card stack — each due habit gets its own full-width HabitCard in the swipeable StreamlinedDashboard carousel, with a single Check In button (see Focus Mode: In-Session Achievement Rewards for what happens after check-in during a session)
  2. Dashboard → Full Dashboard → "Today's practice" — a TodayActions list widget (reachable via the "Full Dashboard" toggle from Focus Mode), with one row and a one-tap check-in button per due habit
  3. Goal Detail Page — "Check In" button in the habit tracking section

What Happens on Check-In

  1. A HabitCompletion record is created for today's date
  2. A GoalEvent with "Checked in!" is created (appears in the timeline)
  3. last_checked_in_date is set to today
  4. The streak is recalculated (see Streak Logic below)
  5. 25 XP is awarded
  6. Followers are notified via feed items
  7. 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 the recurrence_days array
  • 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.


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.

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 appLink are 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's AppLauncher.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.

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 on Goal, max 2048 characters, validated against the scheme deny-set on save.
  • Clearable via an explicit null on update — app_link is wired into UpdateGoal's CLEARABLE_NULLABLE_FIELDS allowlist, the same pattern used by identity_prompt/past_attempt_context/completion_reflection. See Clearing a nullable field vs. omitting it for why this needs explicit handling — omitting the argument and sending null are different client intents that Ruby keyword args collapse by default.
  • Habit → non-habit un-convert: app_link is 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!):

  1. Start from today (or yesterday if not checked in today)
  2. Walk backwards through completion dates
  3. Each consecutive day extends the streak
  4. 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 HabitCompletion with streak_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 touches streak_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:

  1. Finds all active habits where last_checked_in_date < yesterday
  2. Resets their habit_streak to 0
  3. 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 completionRate field
    • 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
  • 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 recurrenceType is set
  • "Goals" shows only goals where recurrenceType is 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:

  1. Signs in with demo account
  2. Creates a daily habit via the goal form
  3. Verifies it appears on the dashboard in "Today's Habits"
  4. Checks in via the dashboard widget
  5. Verifies the habit appears on the goal detail page

Run locally:

bash
cd ionic_frontend
npm run smoke:playwright:staging

Backend Unit Tests

bash
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.rb

Frontend Unit Tests

bash
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:

graphql
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

graphql
# 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

graphql
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

  1. Weekly vs Custom Days: Both use recurrence_days identically. The distinction is purely UX — weekly suggests picking one day, custom days allows multiple.

  2. Interval "never checked in": If last_checked_in_date is nil, an interval habit is always considered due. After the first check-in, the interval starts counting.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Completed habits: Setting a habit as completed: true removes it from the active habits list and "Today's Habits" widget. The active_habits scope filters on completed: false.

Last updated: 2026-09-20 (v4.67 Phase 5, OBJ-4026: added § Streak Repair and Streak Insurance)

Loading…