Skip to content

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:

SurfaceKind sharedTrigger
Achievements.vue headerbadgeMost recently unlocked badge — not a picker
GoalActionBar.vue (rendered from Goal.vue)goal_completion or milestoneThe goal itself if complete, else its most recently completed milestone
StreakDetailsModal.vuestreakAny active streak (streak > 0)

Deliberately absent from every in-the-moment celebrationMilestoneCard'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

  1. The client opens the sheet via useCelebrationShare(), which queries shareableMoment(kind, subjectPublicId).
  2. The resolver (Resolvers::SocialQueries#shareable_moment) re-runs the same ownership/earned-ness check recordShare uses (Social::ShareableSubject) — a caller can never preview, and later record, a card for a subject they don't own or haven't earned.
  3. Social::ShareableMomentCopy maps the resolved subject onto the fields the client's ShareCardData union expects. Despite the module's name, it returns facts, not copy — see Milestone decisions below.
  4. fromShareableMoment() does a straight rename onto the client's typed union — never a derivation. copy.ts supplies the template strings (eyebrow/subject/support lines, verbatim from the UI-SPEC) around those facts.
  5. ShareCard.vue previews the card; shareCardRender.ts exports it to a PNG on share.
  6. webShare.ts hands the PNG + share text + link to the OS via the raw Web Share API, falling back to a clipboard copy.
  7. On a completed share (native or copy-link), recordShare persists a ShareEvent carrying 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 a files array, and both the WKWebView (iOS) and Chromium WebView (Android) that Capacitor apps run inside already implement navigator.share({ files }) natively, so the raw API covers all three platforms with one code path. InviteShareSheet.vue (the prior @capacitor/share call site) was migrated onto it. @capacitor/share itself 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. shareableMoment returns only facts (badgeName, earnedOn, streakDays, …) — never a rendered sentence. Every template string ("BADGE EARNED", "Set a goal. Badges follow.") lives client-side in copy.ts. This is the same split Critical Path's existing share card has always used, and recordShare'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 both ionic_frontend and admin_dashboard. This is the shape that shipped, not the union the milestone roadmap originally preferred — a case where the roadmap's stated preference and master disagree, and master is what's documented here.
  • Reserved-code design. shareableMoment mints a fresh share_code on every query (Resolvers::SocialQueries#shareable_moment) — unpersisted until recordShare is called with it. More codes get minted than ShareEvents exist: opening the sheet three times and sharing once mints three codes and records one event. This is deliberate, not a leak — share_events is 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 streak resolves against User, not Goal. ShareableSubject::SUBJECT_TYPES maps 'streak' => 'User'. StreakDetailsModal.vue's streak is user.streak, a cross-goal, sign-in-date aggregate — there's no single Goal that 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.call in sync_user.rb is unreachable.Mutations::SyncUser#attribute_referral wraps the call in rescue StandardError, but Interaction::Base#call doesn't raise through — it returns a result object. The rescue mirrors an identical, equally-unreachable shape already at ClerkUserSync#call around Gamification::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/share is retired at the code level but still an installed dependency. No src/ import survives (webShare.ts only mentions the package in a comment explaining why it isn't used). It remains registered in ionic_frontend/package.json:73, Android's android/capacitor.settings.gradle:36, and iOS's ios/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 module InviteShareSheet.vue no 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_completed event has fired yet at 0% rollout, and the funnel needs a HogQL join on code because 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_id was the first FK to share_events; the fix for that specific defect already shipped in Phase 5 — see Data Deletion — this ticket is the generalized guard.)
  • @capacitor/share dependency 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 the ShareEvent. Mutations::RecordShare.

Full generated reference: GraphQL queries · GraphQL mutations.


Last updated: 2026-09-13 — initial documentation (v4.56 Phase 6, OBJ-3684).

Loading…