Celebration Sharing Cards
Users can generate a branded card for a badge, a completed goal, a completed milestone, or a streak, and share it off-platform via the device's native share sheet — or copy a referral link if the OS doesn't support that. The share carries a referral code; if it brings in a signup, that signup is attributed back to the sharer.
Status: shipped on master, gated behind celebration_sharing_enabled at 0% PostHog rollout — see Feature Flags. No user sees this today unless they hold a dev-mode override.
Where you can share
Three persistent surfaces, each gaining exactly one share control:
| Surface | Kind shared | Trigger |
|---|---|---|
Achievements.vue header | badge | Most recently unlocked badge — not a picker |
GoalActionBar.vue (rendered from Goal.vue) | goal_completion or milestone | The goal itself if complete, else its most recently completed milestone |
StreakDetailsModal.vue | streak | Any active streak (streak > 0) |
Deliberately absent from every in-the-moment celebration — MilestoneCard's auto-advancing toast, StreakCelebration's balanced/cinematic overlays, and Goal.vue's own completion toast all have no share control. This isn't an oversight; it's a PBC-floor call recorded in the Phase 2 UI-SPEC, Anti-vanity review: a share prompt inside a celebration is a prompt the user didn't ask for, at the moment they're least able to decline it — the anti-social-app rule (MISSION.md, docs/brand/brand.md) forbids that. Sharing is something the user goes and does later, from a home they can return to on their own time, not something the app asks for while they're happy about something else.
How a card gets built
- The client opens the sheet via
useCelebrationShare(), which queriesshareableMoment(kind, subjectPublicId). - The resolver (
Resolvers::SocialQueries#shareable_moment) re-runs the same ownership/earned-ness checkrecordShareuses (Social::ShareableSubject) — a caller can never preview, and later record, a card for a subject they don't own or haven't earned. Social::ShareableMomentCopymaps the resolved subject onto the fields the client'sShareCardDataunion expects. Despite the module's name, it returns facts, not copy — see Milestone decisions below.fromShareableMoment()does a straight rename onto the client's typed union — never a derivation.copy.tssupplies the template strings (eyebrow/subject/support lines, verbatim from the UI-SPEC) around those facts.ShareCard.vuepreviews the card;shareCardRender.tsexports it to a PNG on share.webShare.tshands the PNG + share text + link to the OS via the raw Web Share API, falling back to a clipboard copy.- On a completed share (native or copy-link),
recordSharepersists aShareEventcarrying the same code the link already used.
Milestone decisions this phase records
Four decisions made across Phases 3–5 that a future contributor would otherwise have to reverse-engineer from git history:
- Share path: raw Web Share API, not
@capacitor/share.webShare.ts's own header comment carries the reasoning — Capacitor's web implementation doesn't forward afilesarray, and both the WKWebView (iOS) and Chromium WebView (Android) that Capacitor apps run inside already implementnavigator.share({ files })natively, so the raw API covers all three platforms with one code path.InviteShareSheet.vue(the prior@capacitor/sharecall site) was migrated onto it.@capacitor/shareitself is retired at the code level but still an installed dependency — see Testing & tooling debt below. - Copy/facts split: the server owns facts, the client owns template strings.
shareableMomentreturns only facts (badgeName,earnedOn,streakDays, …) — never a rendered sentence. Every template string ("BADGE EARNED","Set a goal. Badges follow.") lives client-side incopy.ts. This is the same split Critical Path's existing share card has always used, andrecordShare's forgery protection rides the facts (a card can't be built for a subject the resolver didn't independently verify as owned/earned) — which is why the query's field description now says "facts," not "copy" (this phase's DOC-08 fix,rails_api/app/graphql/types/query_type.rb). - Payload shape: an all-nullable field bag discriminated by
kind, not a GraphQL union.ShareableMomentType's own header comment records why: one type avoids inline-fragment codegen churn across bothionic_frontendandadmin_dashboard. This is the shape that shipped, not the union the milestone roadmap originally preferred — a case where the roadmap's stated preference andmasterdisagree, andmasteris what's documented here. - Reserved-code design.
shareableMomentmints a freshshare_codeon every query (Resolvers::SocialQueries#shareable_moment) — unpersisted untilrecordShareis called with it. More codes get minted thanShareEvents exist: opening the sheet three times and sharing once mints three codes and records one event. This is deliberate, not a leak —share_eventsis the roadmap's 5%-target denominator, so it counts completed shares, not offered previews. A client-supplied code that collides with an existing one fails (Social::RecordShare#create_share_event_with_client_code) rather than being silently substituted — a substituted code wouldn't match the link the user already sent.
Referral attribution
A share link is {appUrl}/u/{username}?ref={code} (falls back to the app root if the sharer has no username — see shareUrl.ts). On first sign-in, AttributeShare looks up the ShareEvent by that code and creates a ShareAttribution linking the new user to it — quietly a no-op for an unknown, expired, or fabricated code, and for a sharer following their own link (never self-attributed). The unique index on share_attributions.user_id, not any of this interaction's own logic, is what makes a replayed call idempotent.
Three things that look like bugs and aren't
- Kind
streakresolves againstUser, notGoal.ShareableSubject::SUBJECT_TYPESmaps'streak' => 'User'.StreakDetailsModal.vue's streak isuser.streak, a cross-goal, sign-in-date aggregate — there's no singleGoalthat means what the modal displays.ShareEvent#kind's enum is untouched; only the subject type behind this one kind moved. - More share codes are minted than
ShareEvents exist. Covered above under Reserved-code design — restated here because it reads as a bug on first encounter with the funnel numbers. - The rescue around
Social::AttributeShare.callinsync_user.rbis unreachable.Mutations::SyncUser#attribute_referralwraps the call inrescue StandardError, butInteraction::Base#calldoesn't raise through — it returns a result object. The rescue mirrors an identical, equally-unreachable shape already atClerkUserSync#callaroundGamification::GrantStarterFreeze.call(rails_api/app/interactions/user_identity/clerk_user_sync.rb:41-45) and was kept deliberately for consistency with that neighboring code — don't "fix" it without this context.
Copy drift against the Phase 2 UI-SPEC (reported, not reconciled)
CriticalPathShareCard.vue reads "Faster than 87% of players today." — exactly the comparative/rank shape the UI-SPEC's anti-vanity review (UI-05, rule 2) rules out for the new card family. This is pre-existing and deliberately unchanged: CARD-01 required Critical Path's shipped output be left untouched during the Phase 3 generalization. The UI-SPEC itself names this drift explicitly rather than silently applying the new rule to an existing surface.
Testing & tooling debt carried by this phase
- No Cypress or Playwright coverage exists for celebration sharing (confirmed by grep of both suites). Acceptable while the flag sits at 0% rollout; becomes a real gap the moment it ramps. This is Tess's surface to close, not built here.
@capacitor/shareis retired at the code level but still an installed dependency. Nosrc/import survives (webShare.tsonly mentions the package in a comment explaining why it isn't used). It remains registered inionic_frontend/package.json:73, Android'sandroid/capacitor.settings.gradle:36, and iOS'sios/App/CapApp-SPM/Package.swift:31, plus three now-dead test mocks (tests/unit/setup.ts:156,tests/unit/views/Dashboard.render.spec.ts:113,tests/unit/views/Dashboard.spec.ts:28) that mock a moduleInviteShareSheet.vueno longer imports. Removing the dependency touches native platform config on both mobile platforms — a Codi task, raised separately at milestone close, not done here.
Deferred follow-ups
- REF-05 (real funnel number) — tracked as [OBJ-3706]. No
share_completedevent has fired yet at 0% rollout, and the funnel needs a HogQL join oncodebecause it's cross-person (sharer vs. recipient), not a standard person-based PostHog Funnel insight. - Badge-metadata unification — tracked as [OBJ-3701].
- GDPR erasure FK-guard generalization — tracked as [OBJ-3707]. (
share_attributions.share_event_idwas the first FK toshare_events; the fix for that specific defect already shipped in Phase 5 — see Data Deletion — this ticket is the generalized guard.) @capacitor/sharedependency removal — to be raised as its own issue at milestone close.
Data & privacy
share_events and share_attributions are both excluded from the user data export and are hard-deleted (not exported) on account erasure — see Data Export § What's NOT included and Data Deletion for the full disposition and rationale.
GraphQL surface
query shareableMoment(kind: String!, subjectPublicId: ID!): ShareableMoment— preview, no side effects.Types::ShareableMomentType.mutation recordShare(kind: String!, subjectPublicId: ID!, channel: String, code: String): RecordSharePayload— persists theShareEvent.Mutations::RecordShare.
Full generated reference: GraphQL queries · GraphQL mutations.
Last updated: 2026-09-13 — initial documentation (v4.56 Phase 6, OBJ-3684).