Skip to content

v4.57 — Spendable Streak Freezes

A user who has earned streak freezes can now actually spend one, from a real control on Goal Detail, before the day they know they'll miss.

Summary

Streak freezes had existed as a fully-built backend mechanic since v1.11 — GoalTracking::UseStreakFreeze was implemented, guarded, and spec-covered — with zero frontend call sites. The only caller in the entire tree was Gamification::RepairStreak, and it passed system_granted: true specifically to bypass the freeze-count guard, so even that path never spent a user's earned freeze. Users could watch their freeze count climb every 7 days and had no way to use one.

The gap was found by Dori while closing the v4.47 Concept Legibility audit (row D2) and filed as OBJ-3224, which deliberately left the fix shape open: ship a manual "use a freeze" affordance, or decide freezes should apply automatically and rewrite the copy instead. This milestone settled it in planning, not by guesswork — per-goal habit streaks have no automatic forgiveness at all (Goal#calculate_streak_from_dates is strictly consecutive), and the soft-grace "rest days" the guide had been contrasting freezes against live on a completely different streak (the user-level sign-in streak, not any individual habit). Building an automatic path would have been net-new backend work; the manual path's backend was already sitting there, unused.

What shipped: a bounded, hardened mutation; a UI-SPEC that found and routed a real streak-math bug before any user could hit it; a proactive, mandatory-confirmation spend control that's live to every user with no flag; and a cleanup pass that removed a mount that had never once rendered in production, plus a doc-reconciliation pass that corrected every claim the app no longer matched.

Goal

A user who has earned streak freezes can actually spend one, from a control that exists in the shipped app, within the window where spending one still does anything — and every number the app shows about freezes is one the user can act on or understand.

Scope — What Shipped

  • Backend hardening (GoalTracking::UseStreakFreeze) — a missed_date_in_window? guard bounding which dates a user can freeze, with a system_granted bypass so Gamification::RepairStreak's existing 14-day backfill is untouched; ArgumentError-safe date parsing so a malformed date returns a normal GraphQL error instead of a 500.
  • A narrower, correctness-driven window — the user-facing freeze window is today through +14 days (proactive only; a missed day is Streak Repair's separate job), replacing an initial -7/+14 proposal once the UI-SPEC ruled the affordance proactive-only.
  • A real streak-math fix — a future-dated freeze no longer decrements the displayed streak. calculate_streak_from_dates was starting its walk at yesterday whenever a future-dated HabitCompletion existed; future-dated completions are now filtered out before the walk runs.
  • New frontend componentsStreakFreezeCard.vue (seven states in precedence order) and FreezeDaySheet.vue (the mandatory confirmation step — there is no undo for a spent freeze), mounted on Goal Detail outside the collapsed Analytics accordion so the control doesn't inherit that surface's invisibility problem.
  • An accessibility fix riding alongHabitCalendar.vue's day cells moved from non-focusable divs with title attributes to role="img" + aria-label, per the UI-SPEC's accessibility requirement.
  • Removed legacy code — the dead FreezesRow mount inside StreakDetailsModal.vue, which had never rendered in production (Dashboard.vue never passed the props it needed), along with its stories, spec, and vi.mock.
  • Fixed GraphQL description drift — the useStreakFreeze mutation's description and missedDate argument description, falsified by the window-narrowing change, corrected and regenerated into schema.graphql and docs/architecture/graphql/*.md. The missedDate field itself was deliberately not renamed — it's a misnomer only from the user-facing path's point of view, and renaming a published GraphQL argument is a breaking change.
  • Doc reconciliation — four drifted claims corrected: guide_site/glossary.md's rest-day-vs-freeze contrast (rewritten around which streak each protects), docs/features/habits.md's "retroactively apply a freeze" claim (replaced with the real proactive model), the "freeze status only on Goal Detail" claim, and guide_site/habits-and-streaks.md's how-to (restored now that a real control exists). One claim (docs/product/user-guide.md:281) was verified and correctly left untouched — it already described what shipped.
  • Both changelogs — an engineering entry in /CHANGELOG.md and a user-facing entry in ionic_frontend/CHANGELOG.md, mandatory here (unlike some flagged-at-0% milestones) because this control is unflagged and immediately visible.
  • No feature flagStreakFreezeCard is live to every user the moment this milestone's code merges to master.

Phases

PhaseNameStatusPlansHighlights
1Bound and harden UseStreakFreezeShipped1Backend-only window guard + safe date parsing; all 6 criteria met, zero deviations.
2UI-SPEC — the spend-a-freeze affordanceShipped1Ruled a new dedicated card over reusing existing components; found a real future-date streak-decrement bug in code outside its own scope and routed it forward.
2bBackend corrections the UI-SPEC forcedShipped1Narrowed the window to proactive-only (today..+14) and fixed the future-date streak-walk bug Phase 2 found.
3Build the affordanceShipped1Shipped StreakFreezeCard.vue + FreezeDaySheet.vue, unflagged, mounted on Goal Detail; surfaced and helped resolve three CI-tooling defects along the way (none in its own code).
4Surface cleanup + docs reconciliationShipped1Removed the dead FreezesRow mount, fixed falsified GraphQL descriptions, reconciled four drifted doc claims and both changelogs.

Key Decisions

  • Ship the manual affordance, not an automatic one. OBJ-3224 left this open; planning settled it on evidence — per-goal habit streaks have zero automatic forgiveness (Goal#calculate_streak_from_dates is strictly consecutive), so "make freezes automatic" would have been net-new backend work, not a description of existing behavior. The soft-grace "rest days" mechanic the guide contrasted freezes against lives entirely on the separate user-level sign-in streak.
  • A new dedicated component, not a repurposed one. The UI-SPEC ruled against reusing GoalHabitSection.vue's stat tile or making HabitCalendar.vue days tappable — StreakFreezeCard.vue + FreezeDaySheet.vue ship as their own surface, mounted outside the Analytics accordion specifically so the control isn't inherited-invisible the way the existing freeze count display was.
  • Mandatory confirmation, no one-tap spend. There is no mutation to undo a spent freeze, and placing one on a future day blocks a real check-in that day if the user shows up anyway — so FreezeDaySheet.vue is a required confirmation step, not a convenience the plan could skip.
  • Proactive-only window, not retroactive. A missed day is Streak Repair's job; freezing is for a day you know ahead of time you'll miss. The window is today through +14 days — narrower than the initial -7/+14 proposal — once the UI-SPEC's own reachability ruling made the retroactive half meaningless in practice (the freeze budget evaporates on a break before it could ever be spent retroactively).
  • The future-date streak-walk fix lives in the caller, not the walk function. update_habit_streak! now filters out post-today completions before calling calculate_streak_from_dates, rather than teaching the walk function itself about "future." Verified safe — the walk function has exactly one call site — and keeps it a pure function of the dates it's handed.
  • Ships unflagged. Unlike several recent milestones gated behind a 0%-rollout PostHog flag, StreakFreezeCard has no flag — it's live to every user the instant this code reaches master. That's what made both changelog entries mandatory rather than deferrable.

Requirements Coverage

23 / 23 requirements satisfied (per .planning/milestones/v4.57-spendable-streak-freezes-MILESTONE-AUDIT.md).

CategoryCountStatus
FREEZE-01..066All satisfied (backend window guard, safe parsing, window narrowing, streak-walk fix)
SPEC-01..055All satisfied (UI-SPEC coverage of surface, states, confirmation, accessibility)
UI-01..077All satisfied (the built control, plus the accessibility fix and the FreezesRow removal criterion)
DOC-01..055All satisfied (four doc corrections plus both changelogs)

Full requirements list: v4.57-spendable-streak-freezes-ROADMAP.md on GitHub.

Outcomes

A user with an earned streak freeze can now see it, and spend it, from a real control on their Goal Detail page — no accordion to expand, no dead surface to check first. Spending one requires a deliberate confirmation, since there's no way to undo it. The window is enforced identically on the server and described identically in the UI and the docs: today through 14 days out. A future-dated freeze no longer produces a confusing streak-count drop. Every doc page that mentioned freezes now describes the model the app actually has, not the one it used to imply.

Tech Debt

  • (Phase 3) No Cypress/Playwright end-to-end coverage for spending a freeze — deliberately not built, since a freeze-eligible fixture needs a ≥7-day streak the e2e suite doesn't construct, and Storybook play() + unit tests already exercise every state.
  • (Phase 3) The smoke-account public_id job-output resolver now matches two seeded goals and resolves correctly only by log-entry ordering, not by design — noted as a pre-existing fragility to re-scope before a third fixture depends on it.
  • 7b451ce1c — feat(streak-freeze): bound missed_date window and harden date parsing (OBJ-3686)
  • ed9f8940c — docs(ui-spec): the spend-a-freeze affordance (v4.57 Phase 2, OBJ-3687)
  • 8556c5f81 — fix(goal_tracking): narrow user-facing streak-freeze window to today..+14 (OBJ-3693)
  • 0537520a8 — feat(goal): add spend-a-freeze affordance (v4.57 Phase 3, OBJ-3688)
  • bfd12d701 — fix(streak): remove dead FreezesRow surface, fix freeze GraphQL descriptions (v4.57 Phase 4, OBJ-3718)
  • feb5f3ffd — docs: reconcile drifted freeze docs + changelogs (v4.57 Phase 4b, OBJ-3719)

Last updated: 2026-09-13

Loading…