Achievement Visibility

Status

Decided (MVP): Hybrid — roster visible, details hidden until earned.

Context

The legacy app used a discovery model: the achievements screen was driven by the user’s earned rows (user_achievements), so you only ever saw an achievement once you’d earned it (with a “New achievement unlocked” toast). Unearned achievements were invisible — you had to play to find them. (Confirmed in tmp/range_day/lib/components/powersync/achievements/.)

The new app’s ListAchievements returns the full seeded roster (name, description, criteria), and clients render an “Earned / Locked” split. As originally built, the Locked tab showed each unearned achievement’s full description / unlock requirement — turning the feature into a spoiler-filled checklist and losing the discovery feel (some achievements are clearly meant to be found, e.g. “9/11 Memorial — exact hit factor 9.11”).

Decision

Hybrid. ListAchievements still returns the full roster so users can see how many and which achievements exist (name + client-side icon/color), but for any achievement the caller has not earned, the server masks the detail:

  • description"???"
  • criteria → empty (the machine-readable spoiler is never sent for unearned achievements)

Earned achievements return full description + criteria.

This preserves a sense of progress (you see the roster and your count) while keeping the “what/how” a surprise until you earn it — closer to legacy’s discovery feel without fully hiding the roster.

Why server-side

Masking is done in AchievementServiceImpl::list_achievements (achievement_row_to_proto(row, earned)), keyed off the caller’s earned set. Because all clients (PWA, iOS, Android) call the same ListAchievements RPC and render whatever description the server returns, the behavior is consistent across platforms with no per-client logic — clients cannot leak the hidden detail because they never receive it.

Covered by achievement::service::tests::locked_achievement_masks_description_and_criteria, earned_achievement_reveals_full_detail, and the HTTP-level http_list_achievements_masks_locked_for_caller (proves an authenticated caller gets "???"

  • empty criteria for unearned and full detail for earned).

Deferred

The "???" sentinel is accepted for MVP, but a typed proto field is preferred later so clients render their own placeholder instead of inferring state from display text — e.g. add bool locked (or bool details_masked) to the Achievement message and have clients show a localized “Locked” treatment. (Architect note.)

  • Legacy App Analysis
  • docs/roadmap/items/achievement-api/achievement-api.md
  • docs/roadmap/items/pwa-social-training/pwa-social-training.md
  • docs/roadmap/items/mobile-achievements/mobile-achievements.md