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.)
Related
- Legacy App Analysis
docs/roadmap/items/achievement-api/achievement-api.mddocs/roadmap/items/pwa-social-training/pwa-social-training.mddocs/roadmap/items/mobile-achievements/mobile-achievements.md