Walkthrough: completing a habit¶
One tap on a habit tile in the Habits screen, traced through the Zustand
store, the API client, the FastAPI check-in route, the streak/milestone
domain math, the PostgreSQL write, and back to the celebration toast. All
paths are repo-relative to Geoffe-Ga/adepthood.
The UI is optimistic: the store is updated before the network call, and rolled back if the server rejects it. The server is the source of truth for streaks; the client never computes the authoritative streak for the response.
The happy path, hop by hop¶
-
The tap. In quick-log mode, pressing a habit tile calls
logUnit(itemId, 1)— one unit against the habit (frontend/src/features/Habits/HabitsScreen.tsx:79):else if (mode === 'quickLog') logUnit(itemId, 1); -
Snapshot preparation.
useLogUnitMutationbuilds aLogUnitContextviahabitManager.prepareLogUnit(habitId, amount, tz, date)(frontend/src/features/Habits/hooks/useHabitActions.ts:117,frontend/src/features/Habits/services/habitManager.ts:993-1030). The context capturesprev(the pre-tap habit list) andnext(the post-tap list) by value, so a concurrent second tap rolls back to the right baseline (habitManager.ts:365-370).completed_onis set only for a genuine backfill — "a date that resolves to today is left undefined so the server stamps the completion with the real wall-clock time" (habitManager.ts:1016-1020). -
Optimistic apply. The mutation hook (
frontend/src/hooks/useOptimisticMutation.ts:52) runshabitManager.applyLogUnitContext(ctx), which writesnextinto the Zustand store and persists it to disk (frontend/src/features/Habits/services/habitManager.ts:1037-1040):applyLogUnitContext: (ctx: LogUnitContext): void => { setHabits(ctx.next); void persistHabits(ctx.next); }, -
The network commit.
commitLogUnitContextPOSTs the completion for the habit's current-tier goal (frontend/src/features/Habits/services/habitManager.ts:1048-1055), through the API client'sgoalCompletions.create(frontend/src/api/index.ts:1147-1155), which issuesPOST /goal_completions/with this payload shape (frontend/src/api/index.ts:1117-1125):{ "goal_id": 42, "did_complete": true, "completed_on": "2026-07-30" }An optional deterministic
Idempotency-Keyheader (frontend/src/api/index.ts:249,1136-1146) guards against a double-send from a network blip mid-tap (BUG-API-008). -
Bearer attach. The shared
requestcore resolves the JWT from the registered token getter and attachesAuthorization: Bearer <token>(frontend/src/api/index.ts:330-347) — see the sign-in walkthrough for how that getter is wired. -
The route. FastAPI dispatches to
create_goal_completion(backend/src/routers/goal_completions.py:35-56). The request DTO rejects unknown fields (extra="forbid") and acompleted_onolder than today backfills that day (backend/src/routers/goal_completions.py:22-32). The route resolves three dependencies: the authenticated user (get_current_user,backend/src/routers/auth.py:999), ownership of the goal and its parent habit (resolve_owned_goal_and_habit,backend/src/dependencies/ownership.py:90), and the user's IANA timezone (current_user_timezone,backend/src/dependencies/timezone.py:30). -
Recording is centralized. The route delegates to
record_goal_completioninbackend/src/services/checkin.py:285-330— deliberately shared so the journal suggestion accept flow (issue #818) "records through the identical path" (backend/src/services/checkin.py:1-7). -
Target-day resolution.
_resolve_target_daydefaults to the user's today (in their timezone), rejects a future date, and rejects a backfill older than 30 days (backend/src/services/checkin.py:163-175):_MAX_BACKFILL_DAYS = 30 ... if target_day > today: raise bad_request("completion_date_in_future") if target_day < today - timedelta(days=_MAX_BACKFILL_DAYS): raise bad_request("completion_date_too_old")The 30-day cap exists because "beyond this window a user could manufacture an arbitrarily long streak by logging one consecutive past day at a time" (
backend/src/services/checkin.py:42-45). -
Polarity check.
_subtractive_context_for_goaldecides whether this is an additive habit ("do the thing") or a subtractive one ("abstain from sugar") by querying the habit's clear-tier sibling goal; additive habits getNoneand take the legacy path (backend/src/services/checkin.py:108-152). -
Idempotency gate.
_already_logged_onchecks for an existing completion in the user-local day bounds; if one exists the service returns the current streak withreason_code: "already_logged_today"and writes nothing (backend/src/services/checkin.py:71-95and304-308). -
Unscheduled-miss hold. Logging a miss (
did_complete: false) on a day outside the habit'snotification_dayscadence holds the streak without inserting a row —reason_code: "streak_held"(backend/src/services/checkin.py:309-317,154-160); the cadence check itself isis_scheduled_on(backend/src/domain/streaks.py:22-30), where an empty cadence means every day is scheduled. -
Streak math, one history read.
compute_streak_before_and_afterfetches the goal's completions once, buckets units per user-local calendar day, computes the pre-insert streak, folds the pending completion in, and recomputes (backend/src/services/streaks.py:135-152). The additive rules are owned bycurrent_consecutive_streak(backend/src/domain/streaks.py:69-100):if sorted_days_desc[0] < today - timedelta(days=1): return 0 streak = 1 for i in range(1, len(sorted_days_desc)): if (sorted_days_desc[i - 1] - sorted_days_desc[i]).days != 1: break streak += 1Two exact rules: a recency grace gate — a most-recent completion older than yesterday zeroes the streak ("one stale day is forgiven, two is not",
backend/src/domain/streaks.py:80-84) — and a backward walk that ends at the first gap greater than one calendar day. Days are bucketed in the user's timezone, not the server's (BUG-STREAK-002,backend/src/services/streaks.py:9-14). For subtractive habits the polarity flips:subtractive_current_streakwalks back from today counting days whose logged total is at most the clear-tier threshold — a day with no row at all is perfect abstention — stopping at a transgression or the habit'sstart_date(backend/src/domain/streaks.py:103-125). -
The write.
_persist_and_build_responseinserts aGoalCompletionrow carryinglocal_day(the user-local calendar day) andcompleted_units, inside a savepoint, then commits (backend/src/services/checkin.py:208-236). One-per-day is guaranteed by a migration-owned unique index over(goal_id, user_id, local_day)(backend/src/models/goal_completion.py:22-24). A backfilled day's timestamp is anchored mid-day in the user's zone so it "lands unambiguously inside that local calendar day regardless of DST shoulder days" (backend/src/services/checkin.py:178-190). -
Milestones and reason code.
check_milestonesreturns only the thresholds newly crossed between old and new streak — thresholds are[1, 3, 7, 14, 30](backend/src/services/checkin.py:40) — "preventing duplicate milestone toasts on retries" (BUG-HABITS-008,backend/src/services/streaks.py:203-214). The reason code is derived from the actual streak transition so "the flag never contradicts the number it ships with" (backend/src/services/checkin.py:192-206). -
The response. The route returns a
CheckInResult(backend/src/schemas/checkin.py:24-32), withreason_codepinned to aLiteralof exactly four values (BUG-SCHEMA-003,backend/src/schemas/checkin.py:12-21):{ "streak": 7, "milestones": [{ "threshold": 7 }], "reason_code": "streak_incremented" } -
The toast. Back in the hook, only
onSuccessfires the toast — viahabitManager.buildLogUnitToast(frontend/src/features/Habits/hooks/useHabitActions.ts:107-111,frontend/src/features/Habits/services/habitManager.ts:1075) — "so a server-rejected check-in never flashes a celebration the user didn't earn" (frontend/src/features/Habits/hooks/useHabitActions.ts:40-45).
Where "energy" fits (and doesn't)¶
The check-in path computes streaks and milestones only. The energy
domain module is a separate, pure 21-day plan generator
(generate_plan, backend/src/domain/energy.py:45-62, with
PLAN_DURATION_DAYS = 21 at backend/src/domain/energy.py:41) served
by its own /energy router (backend/src/routers/energy.py); nothing
in services/checkin.py or services/streaks.py calls it. A habit
completion does not recompute any energy plan.
Failure modes¶
- Future or too-old date —
_resolve_target_dayraises400 completion_date_in_future/400 completion_date_too_old(backend/src/services/checkin.py:170-175). The client rolls back the optimistic write and shows the error toast. - Stale goal id (404
goal_not_found) — after onboarding, the store may hold ids the server never issued (issue #282). The failure handler detects exactly this case, rolls back, background-refreshes the habits list, and asks the user to tap again (frontend/src/features/Habits/hooks/useHabitActions.ts:36-37and76-90). - Offline — a non-server error (no HTTP response) keeps the
optimistic state and queues the check-in via
savePendingCheckInfor replay on the nextloadHabits, with an explicit "saved on this device" toast (frontend/src/features/Habits/hooks/useHabitActions.ts:58-75). The request core also fast-fails GETs when the network layer already knows it is offline (frontend/src/api/index.ts:840-845). - Any other server rejection —
rollbackLogUnitContextrestores both the store and the on-disk snapshot — before that fix, a cold start would rehydrate the optimistic state and desync from the server (BUG-FE-HABIT-001,frontend/src/features/Habits/services/habitManager.ts:1057-1066). - Two devices race the same day — the second insert trips the
unique index;
_try_persist_or_idempotentcatchesIntegrityError, rolls back, and returns the idempotentalready_logged_todayresponse instead of a 500 (backend/src/services/checkin.py:239-254). - Corrupt data: duplicate clear-tier goals — the subtractive
context builder surfaces
MultipleResultsFoundas a stable500 duplicate_clear_tier_goalsrather than guessing which threshold applies (backend/src/services/checkin.py:140-148).
Grounded in Geoffe-Ga/adepthood@55eef11, 2026-07-31.