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) — amissed_date_in_window?guard bounding which dates a user can freeze, with asystem_grantedbypass soGamification::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
todaythrough+14days (proactive only; a missed day is Streak Repair's separate job), replacing an initial-7/+14proposal 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_dateswas starting its walk at yesterday whenever a future-datedHabitCompletionexisted; future-dated completions are now filtered out before the walk runs. - New frontend components —
StreakFreezeCard.vue(seven states in precedence order) andFreezeDaySheet.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 along —
HabitCalendar.vue's day cells moved from non-focusabledivs withtitleattributes torole="img"+aria-label, per the UI-SPEC's accessibility requirement. - Removed legacy code — the dead
FreezesRowmount insideStreakDetailsModal.vue, which had never rendered in production (Dashboard.vuenever passed the props it needed), along with its stories, spec, andvi.mock. - Fixed GraphQL description drift — the
useStreakFreezemutation'sdescriptionandmissedDateargument description, falsified by the window-narrowing change, corrected and regenerated intoschema.graphqlanddocs/architecture/graphql/*.md. ThemissedDatefield 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, andguide_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.mdand a user-facing entry inionic_frontend/CHANGELOG.md, mandatory here (unlike some flagged-at-0% milestones) because this control is unflagged and immediately visible. - No feature flag —
StreakFreezeCardis live to every user the moment this milestone's code merges tomaster.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Bound and harden UseStreakFreeze | Shipped | 1 | Backend-only window guard + safe date parsing; all 6 criteria met, zero deviations. |
| 2 | UI-SPEC — the spend-a-freeze affordance | Shipped | 1 | Ruled 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. |
| 2b | Backend corrections the UI-SPEC forced | Shipped | 1 | Narrowed the window to proactive-only (today..+14) and fixed the future-date streak-walk bug Phase 2 found. |
| 3 | Build the affordance | Shipped | 1 | Shipped 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). |
| 4 | Surface cleanup + docs reconciliation | Shipped | 1 | Removed 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_datesis 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 makingHabitCalendar.vuedays tappable —StreakFreezeCard.vue+FreezeDaySheet.vueship 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.vueis 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
todaythrough+14days — narrower than the initial-7/+14proposal — 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-todaycompletions before callingcalculate_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,
StreakFreezeCardhas no flag — it's live to every user the instant this code reachesmaster. 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).
| Category | Count | Status |
|---|---|---|
| FREEZE-01..06 | 6 | All satisfied (backend window guard, safe parsing, window narrowing, streak-walk fix) |
| SPEC-01..05 | 5 | All satisfied (UI-SPEC coverage of surface, states, confirmation, accessibility) |
| UI-01..07 | 7 | All satisfied (the built control, plus the accessibility fix and the FreezesRow removal criterion) |
| DOC-01..05 | 5 | All 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_idjob-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.
Related Artifacts
- Roadmap: v4.57-spendable-streak-freezes-ROADMAP.md
- Milestone Audit: v4.57-spendable-streak-freezes-MILESTONE-AUDIT.md
- PRD: none — this milestone closed a functionality gap (OBJ-3224) found during a docs audit, not a PRD-driven feature.
- Git tag: not yet tagged — rides the next Wednesday 08:00 CT weekly release train per this repo's beta release cadence.
- Merge PRs: #3117 (Phase 1) · #3120 (Phase 2) · #3128 (Phase 2b) · #3129 (Phase 3) · #3145 (Phase 4a+4c) · #3146 (Phase 4b)
Related Commits
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