The wrong handler
The streak’s “Check in” button on the Home screen opened the transaction form. Device-QA round AnF, issue #1 of three.
The other two: the Quests block and Level block on Home were static labels. Non-interactive. Nothing to tap through to. Three gamification blocks rendering correctly from their data, none wired to the right action.
This was Phase N, arriving after PR #10 had already merged. The PR had shipped QuestGenerator, StreakMilestones, dailySaveTarget in UserProfileStore, the streak milestone celebration overlay, and the category-budget quest type. Five features. One branch. Phase N closed the three device-QA issues that slipped through: navigation rewiring, not logic changes.
The reason the fixes were mechanical rather than structural is what this piece is about.
The Cursed Energy Phase C article covered the quest-to-transaction wiring: how QuestGenerator replaced hardcoded mock data with real transaction history. The architectural story behind that wiring is what made the single-PR landing stable. Pure generators with no side effects, a persisted target decoupled from quest logic, and celebration overlays computed after state transitions rather than before.
What shipped in one PR
PR #10 landed:
QuestGenerator(pure). Emits up to 4 quests per day from transaction history and claim state, with no stored state or subscriptions.- A
categoryBudgetQuestslot. Fires when any category has 3 or more outflow transactions in the last 7 days with total spend at or above £20, picking the category with the largest spend. StreakMilestones(pure).crossed(previous:current:) -> Int?returns the highest threshold crossed across 7, 14, 30, 60, and 100 days.- The streak milestone celebration overlay. Flame-themed, triggered at each of those thresholds.
UserProfileStore.dailySaveTarget: Decimal.Published, defaulting to £15, persisted as a Decimal-string inUserDefaults.
All side-branched off the same parent. All landing in one merge.
Five systems with shared concerns, all reading from transaction history, two involving streak state, two involving quest state. The question the architecture had to answer: when five things land at once, what keeps them from coupling?
Pure generators
QuestGenerator takes inputs and returns quests. No stored state. No subscriptions. No side effects.
The same holds for StreakMilestones. crossed(previous:current:) takes two integers and returns an optional integer. It knows nothing about UserProfileStore, nothing about the quest system, nothing about the celebration overlay. It finds the highest threshold between the two counts and returns it, or returns nil.
A sequential delivery (quest system first, streak system a sprint later) makes this discipline easier to maintain. Each addition arrives into a stable system; coupling creeps in gradually and is caught when the next feature touches the edge. In a single PR, the coupling has nowhere to creep: everything arrives at once, and if QuestGenerator depends on StreakMilestones, the reviewer inherits that dependency with no history to explain why it’s there.
Pure types resolve this structurally. QuestGenerator can’t acquire a dependency on StreakMilestones: streak state isn’t in its input signature. The reverse holds for StreakMilestones. Quest state doesn’t exist from its perspective, so the dependency can’t form. The discipline is enforced by the type system before any reviewer reads the diff.
The categoryBudgetQuest slot is the most data-dependent of the quest types, and still fits the model cleanly. Category spend data comes in as a parameter. The generator applies the threshold rule (3 or more outflow transactions, £20 or above, largest-spend category wins) and returns a quest or returns nothing. The category data and the quest claim state are inputs. Nothing else crosses the boundary.
Persisted targets
The SaveX quest needed a real target. A quest that says “save £X today” without a user-set X is a placeholder. PE-005 closed that gap.
UserProfileStore gained dailySaveTarget: Decimal, Published, defaulting to £15, persisted as a Decimal-string in UserDefaults. The generator reads the target at generation time. It doesn’t observe changes; it doesn’t subscribe to the store. If the user updates the target between generation cycles, the next cycle picks up the new value.
A reactive alternative was on the table: QuestGenerator observing dailySaveTarget and re-generating a SaveX quest whenever the value changes. Plausible feature. Also an impurity. The generator’s output would depend on subscription state rather than its explicit inputs. Out of scope in this sprint. The current behaviour is: generate once per cycle, consume the target as it stands.
That’s the correct scope decision. Reactivity earns its keep when targets change often and users expect immediate feedback. Budget app users don’t adjust their daily save target mid-session. Simplest model wins.
Overlays that don’t block
The milestone celebration fires after the streak state updates, not before. That ordering is the constraint that makes overlays safe.
The sequence: streak state updates first, then StreakMilestones.crossed(previous:current:) is called with the old and new values, then if a threshold crossed, the overlay is triggered. The overlay has no path to prevent the state update, delay it, or condition it. The streak is recorded. Whether the animation plays is a separate question.
crossed returns Int?, the highest milestone crossed, rather than Bool. That covers multi-day gaps without additional logic. If the count jumps from 6 to 31 in one check-in, crossed returns 30. One overlay. The lower thresholds don’t queue; the highest wins and the rest are treated as already-cleared.
Overlays that block state transitions are fragile in both directions: if the animation fails to display, the underlying state should still be correct. The milestone celebration is a consequence of the transition, not a component of it. The ledger doesn’t wait for the flames.
What device QA found
Phase N closed three navigation issues. None of the logic needed adjustment.
Three gamification blocks on Home (Quests, Level, Streak) were rendering from correct data. The generator output matched the transaction history. Streak count was right. Level calculation held up. The issue was that tapping the blocks either did nothing or triggered the wrong action.
The “Check in” button pointed at the transaction form handler instead of the streak check-in handler. The Quests and Level blocks had no navigation attached. These are integration issues at the view layer, not bugs in the underlying systems.
Phase N rewired the navigation. The underlying logic didn’t move. That’s the payoff: five systems landed in one PR with correct internal logic, and the device-QA round surfaced three wiring gaps that were fixable without touching the generators, the persisted target, or the overlay sequence.
The constraint
Shipping five features in one PR means the review surface is the full system, not a single addition. Device QA has to hold all five in mind to catch integration issues; individual-feature correctness isn’t sufficient.
The trade-off is that each individual feature is genuinely self-contained. Phase N was three navigation fixes. That outcome isn’t the result of restraint or foresight in isolation. It’s the result of a constraint that made coupling expensive enough to avoid. When everything lands at once, purity isn’t a preference. It’s the price of the merge.



