v4.2 — Reactive Streak Widget
Your home-screen widget now knows whether you've already shown up today — and says so without ever threatening to take your streak away.
Summary
v4.1 shipped a native home-screen widget (iOS WidgetKit + Android App Widget) that renders today's due habits and the current streak — a zero-friction glance surface. It was, by design, static: the widget looked identical whether or not the user had extended their streak today. v4.2 closes that gap, adapting the Duolingo/Chess.com "did I keep it going?" home-screen pattern to Objectuve's anti-social-app brand voice — reactive, but never punitive.
The milestone was planned as three increments merging to a dedicated integration branch, gsd/v4.2-reactive-streak-widget, under the same deferred-master-flip model v4.1 used: Phase 1 established the same-day extendedToday signal and a flame UI-SPEC; Phase 1b folded in a real product gap caught after Phase 1 shipped — the streak visual is a user-selectable metaphor (stones default for everyone, flame supporter-gated), so a flame-only contract would have shown fire to the non-supporter majority — adding an effective streakMetaphor field and a stones UI-SPEC; Phase 2 rendered both states in both metaphors natively on both platforms and fixed a real ship-blocking Android crash that only manual device QA could catch.
That plan is not how the feature actually reached production. By the time the milestone's master-flip PR opened, master had moved on: two other in-flight tracks (an iOS host-chrome fix, OBJ-1351, and the v4.1 milestone's own merge) had touched the same widget source files, and the flip PR came back with 9 conflicting files — including a genuine source conflict, not a mechanical one, in HomeWidgetViews.swift. Rather than force a risky reconciliation, the same feature was verified to have already shipped to master through a separate, independent PR track (closing out defects OBJ-1350, OBJ-1351, and OBJ-1372 along the way) — and that track's implementation was a confirmed strict superset of what the integration branch would have delivered, including a NEW_DAY fix for the exact midnight-staleness edge case this milestone's Phase 1 had flagged as a NIT and deferred. The integration-branch flip PR (#1472) was closed unmerged; the branch was abandoned.
The result on master today: both native widgets swap between a celebratory "extended today" treatment and an encouraging "not-yet-extended" treatment, in whichever streak metaphor the user has picked in-app, on both iOS and Android — fail-closed to the exact pre-v4.2 render whenever the flag is off or a field is missing, and now also handling the day-rollover edge case the original plan didn't. Like v4.1, nothing here has reached staging or production users yet: reactive_streak_widget_enabled stays disabled/0% on master, pending a deliberate rollout decision.
Goal
Make the native home-screen streak widget react to whether the user has extended their streak today — swapping between a celebratory "extended today" state and an encouraging "not yet extended today" state, on both iOS and Android. This is the Duolingo / Chess.com home-screen pattern Josh referenced, adapted to Objectuve's anti-dark-pattern brand voice.
Scope — What Shipped
extendedToday(boolean) and effectivestreakMetaphor('stones' | 'flame') in the widget snapshot (ionic_frontend/src/composables/useWidgetSnapshot.ts) — both derived client-side (signInDates,useStreakMetaphor().effectiveMetaphor(isSupporter)), no new backend/GraphQL surface, both gated behindreactive_streak_widget_enabledand omitted entirely from the snapshot when the flag is off- Two reactive treatments per metaphor rendered natively: iOS (
HomeWidgetViews.swift,WidgetSnapshot.swift) and Android (HomeWidgetProvider.java,WidgetSnapshot.java), each branching on bothextendedTodayandstreakMetaphor, fail-closed to the exact pre-v4.2 single-state flame render - Gain-framed, zero-loss-aversion copy for all four state/metaphor combinations — "Locked in today" / "Today's stone laid" (celebratory), "Add today" / "Add today's stone" (encouraging) — verbatim across both platforms
- A
NEW_DAYstate on both platforms (isNewDayon iOS,isSameLocalDayon Android) that detects whenextendedTodayhas gone stale across a local-midnight boundary and shows a neutral "New day — tap to see today's habits" caption instead of asserting a stale same-day claim — resolves OBJ-1350, the exact timezone NIT this milestone's Phase 1 flagged and deferred - A ship-blocking Android defect found only by real-device manual QA: bare
<View>elements in the widget layout XML aren't onRemoteViews' inflation allow-list and crashed every real-device placement; fixed with<ImageView> - The pre-existing "Enkidu" internal-codename leak in the FIRST_RUN widget state's copy (OBJ-1348, dating to v4.1) — now reads "Open Objectuve to sync today"
- The pre-existing iOS
.containerBackground(for: .widget)gap (OBJ-1351) — adopted for iOS 17+ host-chrome fidelity - Doc updates:
docs/features/home-widgets.md§ Same-day reactive layer (v4.2) and itsNEW_DAYsubsection; new gotcha entry indocs/operations/mobile-builds.md - Vitest + native test coverage for both derivations and the new day-rollover cases (
WidgetSnapshotTests.swift,HomeWidgetProviderTest)
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Widget streak-state contract (design + snapshot) | Shipped* | 1 | extendedToday snapshot field (from signInDates), flame UI-SPEC, reactive_streak_widget_enabled registered |
| 1b | Streak-metaphor contract addendum | Shipped* | 1 | Effective streakMetaphor snapshot field, stones UI-SPEC — closed the flame-only/non-supporter mismatch before Phase 2 could ship it |
| 2 | Native reactive rendering (iOS + Android) + docs | Shipped* | 1 | Both platforms render both states × both metaphors, fail-closed; real-device QA caught and fixed an Android RemoteViews crash |
*"Shipped" here means the design contract and requirements were delivered — not that these specific PRs reached master. See Delivery Path below for what actually happened.
Delivery Path — Planned vs. Actual
This is the part of the record worth reading closely, because the milestone's planning and its delivery diverged.
Planned: three phases merging to the milestone integration branch gsd/v4.2-reactive-streak-widget (PRs #1377, #1379, #1387, #1388), with a single deferred master-flip PR at milestone close promoting the whole thing to master at once — the same house pattern v4.1 and several earlier milestones used.
What actually happened: master moved out from under the integration branch while it accumulated all three phases. An iOS host-chrome fix (OBJ-1351, PR #1467) and the unrelated v4.1 milestone's own merge both touched the same widget source files the integration branch had rewritten. By the time the master-flip PR (#1472) opened, it conflicted on 9 files — including a genuine, non-mechanical source conflict in HomeWidgetViews.swift between master's host-chrome restyle and the branch's reactive-state rendering. Three separate housekeeping PRs (tracked as OBJ-1275, OBJ-1361, OBJ-1370) had already been needed just to keep the planning files current on the branch before this — the code flip died anyway.
Investigation (triggered by a role-boundary question about who should resolve the conflict, escalated to Josh) found that the same feature had, independently and without ever touching the abandoned branch, already reached master through a separate PR track — landed while a related set of defects (OBJ-1350 midnight staleness, OBJ-1351 host chrome, OBJ-1372 Android layout height) were being fixed. Vicki verified directly against master that this independent track was a strict superset: every reactive-rendering capability the branch would have shipped, plus fixes the branch never had. The docs/CHANGELOG diff from branch → master was net -398 lines — the branch had strictly less. Reconciling PR #1472 would have been pure busywork with real regression risk (a careless merge could have dropped the superset's refinements). PR #1472 was closed unmerged; the integration branch was abandoned.
Why this was the right call, not a process failure to paper over: reactive_streak_widget_enabled (0%) was already providing exactly the isolation the integration branch was there to provide. The flag — not the branch — was the real safety mechanism, and it's why the independent track could ship straight to master without incident while the branch sat idle. Process lesson for the next milestone of this shape: prefer flag-gated phase-by-phase merges straight to master over a milestone-spanning integration branch. An integration branch that accumulates a whole milestone while master keeps moving is a merge-conflict generator that a flag already makes unnecessary.
Key Decisions
- Metaphor parity folded in as a same-milestone contract addendum, not deferred to a follow-up — Phase 1 shipped flame-only, which would have mismatched the in-app default (
stones) for the non-supporter majority. Phase 1b landed as a blocking addendum before Phase 2 could merge. - Fail-closed means the exact pre-v4.2 render, not a neutral third state — flag off or fields absent must reproduce the identical v4.1 flame render, with
streakMetaphorignored entirely rather than defaulted to a neutral stones glyph. An early implementation pass got this wrong; caught and fixed before ship. - The
NEW_DAYstate is a real improvement over the original plan, not scope creep — it directly resolves the midnight-staleness NIT flagged at Phase 1 contract time (OBJ-1350) by detecting a local-midnight crossing and rendering a neutral "tap to see today's habits" state instead of a stale same-day claim, on both platforms, at the same precedence slot (afterSTALE, before the habit-progress states). - Manual device QA stays a hard gate for native widget work — the Android
RemoteViewsbare-<View>crash was invisible toaapt2 compile,javac, and a debug harness callingRemoteViews.apply()directly; only a realAppWidgetHostinflation path caught a bug that would have crashed 100% of fresh-install placements. - When an integration branch's flip conflicts for real, verify before reconciling. Reconciling PR #1472 line-by-line against a
masterthat had already shipped a superset would have been strictly worse than closing it — real regression risk for zero benefit. The right response to "does this merge cleanly?" turning up "no" was "does the target already have this?", not "force the merge." - The flag is the isolation mechanism, not the branch.
reactive_streak_widget_enabledat 0% meant the independent track could ship safely straight tomasterwith no user impact, which is exactly why the integration branch turned out to be redundant risk rather than a safety net.
Requirements Coverage
12 / 12 requirements satisfied on master — WIDGET-STATE 7/7, WIDGET-METAPHOR 4/4, DOCS 1/1 (per v4.2-reactive-streak-widget-MILESTONE-AUDIT.md, re-verified against master source 2026-07-12).
| Category | Count | Status |
|---|---|---|
| WIDGET-STATE-* | 7 | All satisfied |
| WIDGET-METAPHOR-* | 4 | All satisfied |
| DOCS-* | 1 | Satisfied |
Full requirements list, phase design detail, and the complete delivery-path reconciliation: v4.2-reactive-streak-widget-ROADMAP.md on GitHub — its "⚠️ Delivery record" section is the fullest account of what happened and why.
Outcomes
Once a rollout is decided, users who place the widget will see it visibly change the moment they've kept their streak alive for the day — a lit flame or a topped stone stack, matching whichever metaphor they've chosen in-app — and a calm, action-forward nudge ("Add today" / "Add today's stone") when they haven't yet, with no fear or loss-aversion language anywhere in the copy. A user who checks the widget after midnight without having reopened the app since sees a neutral "new day" prompt instead of a stale or incorrect celebratory state. Nothing is visible to real users yet: reactive_streak_widget_enabled stays disabled/0% on master, the same deferred-rollout posture v4.1 shipped under.
Tech Debt
- (Open product question) The habit-progress "all done today" state and the reactive "not-yet-extended" caption can render simultaneously with no cross-suppression — surfaced during manual QA on both platforms, not yet resolved.
- (Docs cleanup)
docs/features/home-widgets.mdcurrently has a duplicated "Same-day reactive layer (v4.2)" section — a side effect of the independent PR track's parallel doc merges never being fully reconciled against each other. The correct, up-to-date content is present; the stale duplicate needs a follow-up cleanup pass. - (Process, already addressed by this milestone's own postmortem) Milestone-spanning integration branches are a merge-conflict generator when a kill-switch flag already provides delivery isolation. Recorded in the ROADMAP's Delivery record as guidance for the next milestone of this shape.
Related Artifacts
- Roadmap: v4.2-reactive-streak-widget-ROADMAP.md — includes the full "Delivery record" reconciliation
- Milestone Audit: v4.2-reactive-streak-widget-MILESTONE-AUDIT.md
- PRD: n/a — scoped directly from source issue OBJ-1252 (Josh-confirmed 2026-07-09, ROADMAP comment
16f13dd0), no standalone PRD written - Feature doc: docs/features/home-widgets.md § Same-day reactive layer (v4.2)
- UI-SPECs (design record, preserved from the abandoned branch's planning trail): Phase 1 (flame), Phase 1b (stones)
- Git tag: not yet cut —
reactive_streak_widget_enabledpending a deliberate rollout decision - Delivered via (the real shipping PRs): #1467, #1471, #1475, #1477, #1480, #1482, #1483
- Planned-but-abandoned (historical, never merged): #1377, #1379, #1387, #1388, and the master-flip attempt #1472 (closed unmerged)
Related Commits
2144edabc— docs(planning): land v4.2 ROADMAP on master, reconciled to the real delivery track (OBJ-1254)
Last updated: 2026-07-12 Version: pending a deliberate rollout decision — pre-dates a version tag