v4.56 — Celebration Sharing Cards
A badge, a completed goal, a milestone, or a streak can now become a branded card the user chooses to send — an invitation to act, never a flex, and never uninvited.
Summary
Before this milestone, Objectuve had exactly one working share card — the Critical Path result screen — and a Share control on the Achievements page that had shipped honestly disabled, labelled "coming soon," for months. There was no way to turn a badge, a completed goal, a milestone, or a streak into something a user could hand to a friend, and no way to measure whether sharing brought anyone new into the app at all: a grep for referral/referrer_code/utm_ across the whole Rails app returned nothing.
v4.56 is the first execution slice of the Phase 6 "Growth & Virality" product phase (OBJ-1125) — deliberately scoped to one roadmap item (item 39) rather than mirroring all eight open Phase 6 items into one milestone, since the eight share no common spine and item 39 is the only one whose mechanism is literally acquisition. It generalizes the existing, tested Critical Path renderer into a shared celebration-sharing kit covering four card variants, replaces the "coming soon" placeholder with a real flow, and builds referral attribution from scratch — a shareCode reserved before the OS share sheet opens, carried through the Clerk signup round-trip, and attributed exactly once via a database-level unique index rather than a race-prone flag check.
The milestone's most consequential decision came from its own design review, not from engineering: the UI-SPEC's anti-vanity check ruled that a share prompt inside a celebration moment — the badge-unlock toast, the streak cinematic, the goal-complete overlay — is a prompt the user didn't ask for, at the moment they're least able to decline it. That's the anti-social-app PBC floor doing its job, and it moved every share affordance onto three persistent surfaces the user returns to on their own time instead, at the cost of the highest-converting moment. Everything ships behind a single celebration_sharing_enabled PostHog flag, currently at 0% rollout — this is build-out, not a launch.
Goal
Turn the one-off Critical Path share card into a general celebration sharing capability — badges, goal completions, milestones, and streaks — with a real landing surface and referral attribution, so the roadmap item's own success metric ("shared cards drive 5%+ of new signups (referral attribution)") is measurable rather than aspirational.
Scope — What Shipped
- Backend spine (Phase 1):
share_eventstable (PublicRecord,acts_as_paranoid),Social::RecordShareinteraction, therecordSharemutation, and theshareableMomentGraphQL query that authors every card's facts server-side so a client can never claim an unearned achievement. - Design contract (Phase 2): a UI-SPEC covering four card variants (badge, goal completion, milestone, streak) at two aspect ratios (1080×1080 feed, 1080×1920 Instagram/Stories), every interaction state enumerated, and an explicit, recorded anti-vanity review against the PBC floor.
- Generalized render kit (Phase 3):
ionic_frontend/src/share/— a variant-driven canvas renderer, Storybook stories per variant per ratio, and the milestone's one settled technical decision: the raw Web Share API, not Capacitor'sShare.share(), is the single sanctioned path for handing a card image to a native share sheet. Critical Path's existing renderer became variant zero, provably byte-comparable to its pre-refactor output. - Real surfaces (Phase 4): the "coming soon" Share control on
Achievements.vuereplaced with the live flow; the same flow mounted onGoalActionBar.vueandStreakDetailsModal.vue; a pre-existing PBC-floor violation onCelebrationCinematic.vue(a raw, unreviewednavigator.share()button on a celebration overlay) found and removed in the same pass; every surface gated byuseFeatureFlag('celebration_sharing_enabled'). - Referral attribution (Phase 5): a reserved
shareCodeminted before the share sheet opens, idempotent attribution via a unique database index, reuse of the app's existing pre-router?ref=capture pipeline rather than a second one, and a fallback link for the (nullable) case where a sharer has nousernameto build a/u/:usernameprofile link from. - Documentation (Phase 6, this milestone's own close):
docs/features/celebration-sharing.md, thecelebration_sharing_enabledflag entry indocs/development/feature-flags.md, item 39 ticked indocs/product/roadmap.md, and a CHANGELOG entry. - Flag:
celebration_sharing_enabled— registered in bothFEATURE_FLAGSand PostHog project 368400, currently 0% rollout.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Share spine | Shipped | 1 | share_events, Social::RecordShare, recordShare/shareableMoment, the flag itself |
| 2 | UI-SPEC — celebration share card family | Shipped | 1 | Four variants, two ratios, the anti-vanity review that reshaped Phase 4's scope |
| 3 | Share-card kit | Shipped | 1 | Generalized src/share/, Critical Path as variant zero, settled Web Share API vs. Capacitor |
| 4 | Wire the real celebration surfaces | Shipped | 1 | Replaced the placeholder, reconciled a real Phase 1/3 payload-shape conflict, removed a pre-existing PBC-floor violation |
| 5 | Referral attribution | Shipped | 1 | Reserved share codes, idempotent attribution, a GDPR-erasure BLOCKER found and fixed in-phase |
| 6 | Documentation | Shipped | 1 | This page, the feature doc, the flag entry, the CHANGELOG entry |
Key Decisions
- One roadmap item, not eight. OBJ-1125's kickoff checklist read as if all eight open Phase 6 items should become phase children of one milestone. Rejected: the eight share no common spine (a data migration, a new Firebase hosting target, and design-asset work have nothing to sequence around), and item 39 is the only one whose mechanism is acquisition — the question the Phase 6 project exists to answer. The other seven stay open as scoping inventory for future milestones.
- The server owns the facts, the client owns the template strings. Phases 1 and 3 ran in parallel and independently chose incompatible payload shapes — Phase 1 authored server-side prose, Phase 3 built a facts-based renderer. Reconciled at the Phase 4 boundary by having
shareableMomentreturn structured, server-validated facts (badge name, goal title, milestone position, streak count) that the client's own copy templates fill in — never inventing, parsing, or re-deriving a fact client-side. This is the same split Critical Path sharing has always used. - No share control on a celebration moment. The UI-SPEC's anti-vanity review (UI-05) ruled that a share prompt inside a celebration — the badge toast, the streak cinematic, the goal-complete overlay — is a prompt the user didn't ask for at the moment they're least able to decline it. The share affordance lives only on three persistent surfaces the user returns to on their own time: the Achievements header, the goal action bar, and the streak details modal. This traded away the highest-converting moment on purpose, and the trade is recorded, not glossed over.
- The raw Web Share API, not Capacitor's
Share.share(). Two share paths existed in the app before this milestone and disagreed; Phase 3 settled it in favor of the Web Share API (webShare.ts) as the single path capable of carrying a PNG to a native destination like Instagram Stories, migratingInviteShareSheet.vueonto it in the same phase. - A database unique index, not a check-then-insert flag, guarantees attribution runs exactly once. The same lesson
share_events.code's own uniqueness already encoded: a flag check is a race, the DB constraint is the actual safety net. streak's subject repointed fromGoaltoUsermid-milestone.StreakDetailsModal.vuerenders a cross-goal streak aggregate that no singleGoalrow could represent. Decided at the milestone level: repoint the one kind's subject type rather than invent a new kind or a proxyGoal— a free change while the flag sits at 0% with no live rows yet.- Rarity is not shown on cards. No badge-rarity token cleared a 3:1 contrast floor at both gradient stops in testing (epic measured 1.46:1 / 1.01:1); the UI-SPEC explicitly allowed "rarity is not shown" as a valid answer, and that's what shipped. The tiers stay backend-only, recoverable by a future milestone.
Requirements Coverage
47 / 48 requirements satisfied (per v4.56-celebration-sharing-cards-MILESTONE-AUDIT.md) — the one unmet requirement, REF-05 (a live PostHog referral-funnel number), is deliberately deferred rather than unaddressed: it cannot be measured before the flag ramps past 0%, since no real share_completed events exist yet.
| Category | Count | Status |
|---|---|---|
| SHARE-* (backend spine + surface wiring) | 17 | All satisfied |
| UI-* (design contract) | 6 | All satisfied |
| CARD-* (render kit) | 6 | All satisfied |
| REF-* (referral attribution) | 9 | 8 satisfied, 1 deferred (REF-05 → OBJ-3706) |
| DOC-* (documentation) | 10 | All satisfied |
| M-* (milestone success criteria) | 7 | All satisfied (M-04's attribution mechanism ships; its funnel measurement is the same deferred REF-05) |
Full requirements list: v4.56-celebration-sharing-cards-ROADMAP.md on GitHub.
Outcomes
A user can now turn a badge, a completed goal, a milestone, or a streak into a branded card and hand it to their own share sheet — or copy a link if the platform can't share a file — from three places in the app they'd naturally return to. Every fact on that card is server-validated, so nothing rendered can misrepresent what was actually earned. A shared link that brings in a new signup is tracked as a real, idempotent referral rather than a guess, once the feature is live for real users. None of this is reachable by a real user yet: celebration_sharing_enabled sits at 0% rollout, so this milestone is complete build-out awaiting a ramp decision, not a launch.
Tech Debt
- (Phase 5) REF-05 — the referral funnel's live conversion number — can't be measured until the flag ramps past 0%; tracked as OBJ-3706.
- (Phase 5) The GDPR erasure FK guard (
schema_fks_to) is invoked for only two tables (users,goals); this milestone's newshare_events/share_attributionstables were the third recurrence of a defect class that guard should already cover generally. Tracked as OBJ-3707 — the highest-value carry-forward item from this milestone. - (Phase 4) Badge metadata between
badge_catalog.rband the frontend'sBadgeCollection.jsnow has a parity test, not full unification. Tracked as OBJ-3701. - (Phase 3 / Phase 6)
@capacitor/shareis retired at the code level but still an installed dependency on both Android and iOS platform config; full removal is native-platform work outside a docs phase's reach. Tracked as OBJ-3711. - (Phase 6) No Cypress/Playwright coverage exists yet for celebration sharing — acceptable while the flag is at 0%, becomes real debt at ramp (Tess's surface).
- (Phase 4)
ShareSheet.vue's ratio radiogroup lacks rovingtabindex/arrow-key navigation, matching ~20 pre-existing radiogroup sites project-wide; scoped fix, not re-filed as its own ticket.
Related Artifacts
- Roadmap: v4.56-celebration-sharing-cards-ROADMAP.md
- Milestone Audit: v4.56-celebration-sharing-cards-MILESTONE-AUDIT.md
- PRD: none — roadmap item (
docs/product/roadmap.md§ Phase 6, item 39), not a standalone PRD - Git tag: none yet — ships on the next tag that includes these merges, per the weekly release train
- Merge PRs: #3119 (Phase 1) · #3118 (Phase 2) · #3121 (Phase 3) · #3135 (Phase 4) · #3139 (Phase 5) · #3141 (Phase 6)
Related Commits
5463fa6dbde228cedc8c9aa231309f61162e3b5b— Phase 1: share spine (share_events, RecordShare, GraphQL, kill switch)3ccaac8e1b2481bac3dee1a8fd82a8ecb9a6cb15— Phase 2: celebration share card family design contract8acfbac8ddf4778594dfe36a9305a2914cd66b9a— Phase 3: generalize the celebration share-card renderer intosrc/share47472ed24b9c96309c5a4095fc24cd6605e4d0bf— Phase 4: wire real celebration sharing surfaces (SHARE-07–17)4646a6ca30bf6894779eb2a8c52bd5bef1765dc7— Phase 5: reserve a shareCode before the sharebf7b191ffc38c85b3c874043c2ab9c26f07169f1— Phase 6: document Celebration Sharing Cards
Last updated: 2026-09-13 Version: v4.8.4 (app version at time of this milestone's close — celebration sharing itself is not yet on a production tag; see git tag above)