Summary
Achievements is a badge/trophy system layered on top of Scout's existing patch-collecting mechanic. It shipped in PR #188, "Achievements: 144-achievement catalog, in-person earning, and Wild Cards" (a852f89b, merged 2026-08-31). A catalog of 144 achievement rows lives in the achievements table, split across six "families" (type, volume, geo, app, behavior, secret). Achievements are earned automatically — evaluated server-side, either when a patch is collected/uncollected (POST /api/sync/push) or when the client records a one-shot "feature event" (POST /api/feature-events, e.g. opening a screen for the first time). Earning pays XP and can advance a daily streak (UserProgression). Only patches collected in person (GPS-sourced, not imported from the camera roll) count toward any patch-derived achievement.
Approval model: every one of the 144 seeded achievements is created with isApproved: false, and GET /api/achievements returns only rows where isActive && isApproved. Achievements are therefore gated by content, not by a feature flag — there is no boolean anywhere that turns "Achievements" globally on; each row is approved individually in the admin UI and published to prod, and approval is retroactive. Achievements are approved and LIVE in production today (confirmed by the product owner, 2026-08-31); the false default describes what a fresh seed produces, not the current prod state.
Status (shipped / beta-badged / flagged off)
- Shipped, not gated behind a feature-flag registry entry —
grepofbackend/src/feature-flagsandmobile/src/configfor "achievement" returns nothing. - Gated by content, not code — and currently ON in prod: all 144 seed rows are created with
isApproved: false(backend/scripts/seed-achievements.ts:119, confirmed by the Prisma schema comment atbackend/prisma/schema.prisma:1790-1793).AchievementsController.list()filters toisActive: true, isApproved: true(backend/src/achievements/achievements.controller.ts:55-58). Until an admin approves rows, the mobile screen and the unlock deck have nothing to show. That approval has been done in production — achievements are live for end users. - Approval is per-row and retroactive: the moment a row's
isApprovedflips to true, the nextaward()call (patch sync or a feature event) grants it to every user who already qualified — no backfill job (backend/src/achievements/achievements.service.ts:24-31). is_approvedis one of the published columns for theachievementcontent entity (backend/src/content-publish/content-entities.ts:515-548), so turning achievements on in production is done through the normal local-author →/admin/publish→ prod workflow, same as patches/collections.
User-facing surfaces
- Screen:
mobile/app/achievements.tsx, route nameachievements, registered as a pushed stack screen withheaderShown: false(mobile/app/_layout.tsx:492). - Deep link:
scout://achievements, registered inmobile/src/dev/deepLinkRoutes.ts:41under category "Core". - Drawer entry point:
mobile/src/components/navigation/drawerSections.ts:210—{ route: 'achievements', label: 'Achievements', icon: AwardIcon, href: '/achievements' }. - Unlock deck:
mobile/src/components/achievements/AchievementDeck.tsx, rendered globally fromapp/_layout.tsx. A bottom-of-screen card stack that announces newly-earned achievements (name, description, art) as they arrive from a sync push or a feature-event response; holds ~3.2s, then exits. Tapping it navigates to/achievements. It is suppressed while any patch celebration is still owed — one on screen, or one still queued behind it — and for a further 600ms (DECK_SETTLE_MS) after the last one clears, so the modal's dismissal and the card's entrance + sound don't overlap. The gate isuseSettled(okToCelebrate && !celebrationOwed, DECK_SETTLE_MS)inCelebrationWatcher(mobile/app/_layout.tsx). Until 2026-09-02 it asked only whether a celebration was on screen right now, so in a nested unlock the gap between two celebrations read as "nothing celebrating" and a card played for a beat before the next celebration covered it (board ticket 5e14ab53). - Grid:
mobile/src/components/achievements/AchievementGrid.tsx— 3-column grid grouped under sticky family-name headers ("Places", "Milestones", "Geography", "Using Scout", "Habits", "Wild Cards"), earned cells full-color, locked cells tinted to a grey silhouette of the same art (no third/hidden state). - Detail sheet:
mobile/src/components/achievements/AchievementSheet.tsx— one body for every achievement, led by the badge: art, name, Earned/Locked chip, description — all centred on one axis — then the numbered "how to earn" steps and an optional "Take me there" deep-link button, both left-aligned. The art is62%of the sheet's width (capped at 240pt) rather than the 78pt thumbnail it used to be, which made the badge smaller in the sheet than in the grid cell you tapped to open it. There is no longer a separate withheld/redacted body for secrets. - Progress header: on the achievements screen —
${earnedCount} / ${total}and a percentage bar, computed client-side from the list response. - Admin review screen:
backend/admin-uihas an Achievements page (AchievementListRow,AchievementEditorForm,AchievementPreview,DeleteAchievementDialog— underbackend/admin-ui/src/components/achievements/) backed bybackend/src/admin/api/achievements-api.controller.ts, where a human approves rows (individually or via bulk-approve) and edits copy/properties.
How it works (end-to-end mechanism)
- Trigger. Either the user collects/uncollects a patch (mobile calls
POST /api/sync/push, handled bySyncService.awardAchievementsinbackend/src/sync/sync.service.ts:653-670), or the client records a feature event (POST /api/feature-events,backend/src/achievements/feature-events.controller.ts). Both paths callAchievementsService.award(userId, { announce }). - Context assembly.
award()(backend/src/achievements/achievements.service.ts:33-150) pulls, in parallel: all active+approvedAchievementrows; the user'sUserPatchrows filtered tosource: 'gps'(IN_PERSON_SOURCE,backend/src/achievements/feature-context.ts:12); the user's already-earnedUserAchievementids;buildFeatureCounts()(derived counts from real tables: posts, comments, votes, board favorites, poll votes, message likes, patch photos, submissions, feedback, purchases, album shares, qualified referrals, multi-stop trips, fully-collected trips); and the user'sUserFeatureEventrows (one-shot tokens likeopened_compass). - Evaluation. These are folded into a pure, synchronous
EarnedContextobject (patches + featureEvents + featureCounts, no DB/clock/IO) and handed toAchievementEvaluatorService.evaluate()(backend/src/achievements/achievement-evaluator.service.ts), which filters the achievement list down to qualifying ids by dispatching onfamily:type— count of the user's patches whosecollectionTypematchesachievement.placeType, compared againstthreshold.volume— total patch count vsthreshold.geo— count of distinctpatch.statevalues vsthreshold.app—qualifiesApp(): splitsachievement.metricon the last>=. A bare token (opened_compass) checksfeatureEvents.has(token); akey>=Nshape checksfeatureCounts[key] >= N, failing closed (missing key reads as 0) on any typo.behavior—evaluateBehavior()insecret-rules.ts, a switch over 4 wired slugs (patch-rush,groundhog-day,night-moves,early-bird; a 5th,photo-finish, is defined in the catalog but permanentlyfalse— it needs per-photo countsEarnedContextdoesn't carry).secret— looked up inSECRET_RULES(aRecord<slug, predicate>keyed by achievement slug, not a generic engine); a slug with no entry never fires.
- Award. Newly-qualifying ids (excluding already-owned ones) are inserted into
UserAchievementviacreateManyAndReturn({ skipDuplicates: true })— chosen specifically so two concurrentaward()calls (the read path's lazy seed and a sync push both fire on app open) each only get credit for the rows they actually inserted, not the full candidate list (backend/src/achievements/achievements.service.ts:102-118). - XP and streak.
payXp()sumsxpValue(flat 10 per achievement,backend/scripts/seed-achievements.ts:117) for whatever was newly earned this call, and — independently — advances a daily streak if the user had any patch activity this call (progression.service.ts'sadvanceStreak/levelForXp). Both are written inside aSELECT ... FOR UPDATEtransaction againstuser_progressionto serialize concurrent callers and avoid lost updates. - Response. The awarding endpoint returns
newlyEarned(only whenannounce: true) as an array ofEarnedAchievementDto(id,name,description,artUrl,deepLink— a deliberately narrow projection, not the raw row). The mobile client enqueues these intouseAppStore().achievementQueue, which feedsAchievementDeck. - Retroactive-seed suppression. The very first time
award()runs for a long-history user with existing patches but zeroUserAchievementrows (detected viahasAchievement/hasPatchchecks in bothSyncService.awardAchievementsandFeatureEventsController.awardForEvent), it passesannounce: false. The rows are still written and XP is still paid, but nothing is reported as "newly earned" — this avoids dumping dozens of unlock cards on one user in one shot.AchievementsController.list()has its own equivalent lazy-seed path (seedIfNeverEvaluated, alwaysannounce: false) so a user who opens/achievementsbefore their next sync still sees correctearnedflags. - Read path.
GET /api/achievementslazily seeds if never evaluated, then returns everyisActive && isApprovedachievement (unearned ones included in full — no redaction) plus the user'sUserProgression(xp,level,currentStreak,longestStreak, defaulting to 0/1/0/0 if no row exists yet).
Data model (Prisma models and key fields)
backend/prisma/schema.prisma:1766-1871
Achievement(@@map("achievements")) — content, published like patches.id— opaqueuuid(), deliberately decoupled fromslug/name(fixed by migration20260828170000_achievement_opaque_id— the id used to equal the slug, which leaked a "secret" achievement's identity through the old redaction boundary).slug(unique, the stable cross-database natural key),name,description.family— one oftype | volume | geo | app | behavior | secret(string, not an enum).metric— free-form rule string, e.g.count(collection_type=state_park)>=1,community_post_created>=1, or a bare event tokenopened_compass.threshold(default 1),placeType(nullable — only the 36type-family rows populate it),deepLink(nullable,scout://...),steps(JSON array of strings, the "how to unlock" copy),sourcePolicy(nullable string,'live' | 'any' | null— recorded but not enforced, see Edge cases),xpValue(default 10),isSecret(bool, derived fromfamily === 'secret'),isActive(retired-vs-live),isApproved(default false — human review gate),sortOrder,artUrl.
UserAchievement(@@map("user_achievements")) —userId,achievementId(FK, cascade delete),earnedAt, unique on(userId, achievementId). Awards are non-revocable (no code path removes a row except deleting the achievement itself or wiping user data on account deletion).UserProgression(@@map("user_progression")) — one row per user, created lazily on first award:xp,level,currentStreak,longestStreak,lastActivityDate(date-only).levelis alwayslevelForXp(xp)— a pure function (floor(sqrt(xp/10)) + 1), never a stored/independent counter.UserFeatureEvent(@@map("user_feature_events")) — one row per(userId, event),firstAttimestamp. Records only "ephemeral" actions (screen opens, etc.) that no other table already remembers; everything the DB already tracks (posts, votes, purchases…) is counted live from its own table instead (buildFeatureCounts).
API surface
All four endpoints require JwtAuthGuard (signed-in users only); the admin ones additionally require AdminGuard / ContentWriteGuard.
GET /api/achievements—backend/src/achievements/achievements.controller.ts. Returns{ achievements: AchievementDto[], progression }.AchievementDtoincludes every field (id, slug, name, description, family, deepLink, steps, isSecret, artUrl, sortOrder, earned) for every approved+active row, earned or not — no per-family redaction.POST /api/feature-events—backend/src/achievements/feature-events.controller.ts. Body{ event: string },eventmust be one ofKNOWN_FEATURE_EVENTS(a hardcoded allow-list of ~22 tokens covering screen-opens, photo import, trip actions, collection prefs, referral, etc.) or the request 400s. Upserts aUserFeatureEventrow (idempotent), then evaluates and returns{ recorded: true, newAchievements: EarnedAchievementDto[] }.POST /api/sync/push—backend/src/sync/sync.service.ts(not an achievements-owned endpoint, but where patch-derived achievements are actually awarded). Response includesnewAchievements: EarnedAchievementDto[]alongside the normal sync payload.- Admin CRUD,
backend/src/admin/api/achievements-api.controller.ts, mounted at/api/admin/achievements:GET /— list all rows (any state).GET /:id— one row.GET /:id/earned-count— how manyUserAchievementrows reference it (pre-flight for delete's blast-radius warning).PATCH /:id— partial update of copy/properties only (name, description, deepLink, steps, sourcePolicy, xpValue, isActive, isApproved).family,placeType,thresholdare immutable here (they're seeded rule content, tied tometric) — attempting to change one 400s. Validates: name required, ≤3 words only if the name actually changed (grandfathers pre-existing 4-word names), no duplicate name, known family,placeTypethreshold not exceeding the catalog's supply, secrets can't carry adeepLink, and anydeepLinkmust be a registeredscout://route (checked against the full mobile route registry, not the admin dropdown's curated subset).POST /bulk-approve— body{ ids: string[] }, flipsisApproved: trueon all of them in oneupdateMany, no other validation re-run.DELETE /:id— hard delete, cascades and orphans anyUserAchievementrows referencing it; returns the orphaned-row count. The admin UI's default remediation is deactivating (isActive: false) instead.
Key files
backend/src/achievements/achievement-rules.ts—RARE_SUPPLY_CEILING(=15),loadDeepLinkRegistry(),loadSupply(),hasTimeWindow(),isUnearnable(),isCompoundDifficulty(),constrainingType(). Pure predicates used by both the admin validator and a live drift-audit test suite (not by runtime evaluation). See "Configuration and flags" below for the unusual repo-root path resolution this file does.backend/src/achievements/secret-rules.ts—SECRET_RULES(19 wired predicates for secret/"Wild Card" achievements),DORMANT_SECRETS(11 named-but-never-firing secrets and why, e.g.plane-crazy,dawn-patrol,golden-hour— all need lat/lng or sunrise/solstice mathEarnedContextdoesn't carry),evaluateBehavior()(4 wiredbehavior-family predicates + 1 permanently-false).backend/src/achievements/achievement-evaluator.service.ts— the pure, synchronous per-family dispatcher (AchievementEvaluatorService.evaluate).backend/src/achievements/achievements.service.ts—award(): context assembly, insert-with-race-safety, XP/streak payment.backend/src/achievements/progression.service.ts—levelForXp(),advanceStreak().backend/src/achievements/feature-events.controller.ts—KNOWN_FEATURE_EVENTSallow-list,POST /api/feature-events.backend/src/achievements/feature-context.ts—IN_PERSON_SOURCE = 'gps'(line 12),buildFeatureCounts().backend/src/achievements/achievements.controller.ts—GET /api/achievements, thetoDtocomment explaining the secret→Wild-Card change,seedIfNeverEvaluated().backend/src/achievements/earned-achievement.dto.ts— the shared five-field DTO both awarding endpoints return.backend/scripts/seed-achievements.ts— one-time, create-only import of the 144-row catalog from a fixture; never overwrites an existing row by slug.backend/scripts/upload-achievement-art.ts— uploadsmobile/research/images/achievements/<slug>.pngto S3 (achievements/prefix) and writesbackend/scripts/data/achievement-art-manifest.json(slug → public URL, committed, 144/144 entries present).mobile/app/achievements.tsx— the screen. Split into the house view-model pattern (same shape asmobile/src/screens/FavoritesScreen.tsx):AchievementsScreenViewModelImplowns every hook,AchievementsScreenLayoutis a pure layout taking the view model as a prop, and the exported puresummarizeAchievements(achievements, filterFamily)does all the derivation (total, earned count, 0..1 progress, first-appearance family order, filtered list). The default export is<AchievementsScreenLayout viewModel={AchievementsScreenViewModelImpl()} />.mobile/src/dev/mocks/achievements.tsx— the screen's entry in the DEV Screen-mocks gallery (Drawer → DEV → Screen mocks,scout://dev-screen-mock/achievements?state=<slug>). Ten states rendered from plain view-model objects through the screen's own layout, over the WHOLE 140-row catalog (mobile/src/dev/fixtures/achievementCatalog.ts, generated fromresearch/js/data.generated.js): standard (23/140), nothing-earned (0/140), everything-earned (140/140), one achievement, filtered to Wild Cards (the real 49), an unlabelled family slug falling back throughfamilyLabel, long names, detail sheet open, loading and empty. Registered inmobile/src/dev/screenMocks.tsx.mobile/src/components/ui/FilterPill.tsx— the family filter's pill rail, extracted from this screen and now shared with Favorites.mobile/src/components/achievements/AchievementGrid.tsx,AchievementSheet.tsx,AchievementDeck.tsx,AchievementsSkeleton.tsx,achievementDisplay.ts(FAMILY_LABELS—secret→ "Wild Cards",deepLinkToPath()).mobile/src/hooks/useAchievements.ts,mobile/src/query/queries/achievements.ts— data fetching (GET /api/achievements, 5-min stale time, gated on!!accessToken).mobile/src/api/featureEvents.ts—recordFeatureEvent(), fire-and-forget client forPOST /api/feature-eventswith per-session dedupe.backend/src/admin/api/achievements-api.controller.ts— admin CRUD,validateAchievement(),mergeAchievementInput().backend/src/content-publish/content-entities.ts:513-548—achievementcontent-entity registration (published columns, natural keyslug).
Configuration and flags
- No feature flag gates Achievements on/off as a whole. The gate is content-level:
Achievement.isApproved(defaultfalsefor all 144 seeded rows), edited per-row in the admin UI and pushed to prod via the normal content-publish flow (/admin/publish). RARE_SUPPLY_CEILING = 15(achievement-rules.ts:11) — not a runtime gate at all. It's the threshold used by an authoring-time/audit-time check: a place type with fewer than 15 catalog patches is "rare," and pairing a rare type with a time-window constraint (isCompoundDifficulty) risks an achievement that can never realistically fire for anyone, even though a naive supply check (threshold <= supply) would pass. Enforced byachievement-rules.live.spec.ts, which runs against the real 2,155-patch catalog (npm run test:live, explicitly not in CI or the db-tier suite — described in the file as "audits, not regression tests").achievement-rules.tsresolvesmobile/src/dev/deepLinkRoutes.tsat runtime by walking up from__dirnameuntil it finds a directory containing both abackend/and amobile/sibling (findRepoRoot(),achievement-rules.ts:31-50), rather than a fixed../../../relative path. This exists because the backend runs from two different depths —backend/src/achievementsunder ts-jest/ts-node, but one directory deeper atbackend/dist/src/achievementsin the compiled server — and a fixed relative count is only correct for one of them; the walk-up works from either. The registry file is then parsed as text (regex overpath: '...'occurrences), not imported, because this backend project cannot import mobile's TypeScript (separate project/dependency tree). This is deliberately the FULL registry, not the curated Core/Patches/Flows subset the admin dropdown offers, so that an unrelated PATCH to a pre-existing achievement doesn't spuriously 400 on a legitimately-registered-but-uncurated deep link.KNOWN_FEATURE_EVENTS(feature-events.controller.ts:34-48) is a hardcoded allow-list, not a DB table or flag — adding a new instrumentable client action requires a code change here.
Edge cases and known limits
sourcePolicyis recorded but not enforced. TheAchievement.sourcePolicycolumn ('live' | 'any' | null) exists in the schema and is populated from the seed fixture, but nothing reads it at evaluation time —user_patcheshas nosource-policy-aware filtering beyond the single, blanketIN_PERSON_SOURCErule applied uniformly to every patch-derived family. (Comment: "user_patches has nosourcecolumn... do not add filtering here without adding the column first,"achievement-evaluator.service.ts:30-32— note this predates and is superseded in spirit by thesource: 'gps'filter now applied inachievements.service.ts, but per-achievementsourcePolicynuance is still unused.)- Camera-roll-imported patches earn nothing. Only
UserPatchrows withsource: 'gps'count toward any patch-derived achievement (type, volume, geo, behavior, most secrets). A patch collected via photo import (source: 'import') or predating thesourcecolumn ('unknown') is invisible to the evaluator. photo-finish(behavior family) is permanently unearnable — it needsPatchPhotocounts, whichEarnedContextdoesn't carry. It falls throughevaluateBehavior'sdefault: false.- 11 secret/"Wild Card" achievements are dormant by design (
DORMANT_SECRETSinsecret-rules.ts:231-249) —plane-crazy,border-line,close-quarters,long-way-round,due-north,coast-to-coast,four-corners,local-hero,continental-drift,golden-hour,picture-this,summer-opener,dawn-patrol— all need lat/lng coordinates or astronomical (sunrise/solstice) calculations thatEarnedContextdoes not carry. That's 13 names listed (some may be intentionally aspirational catalog entries); they are named in code specifically so a never-firing achievement is visible to developers rather than silently discovered by a player who can never earn it. - The mobile screen has no error state — a failed fetch looks like an empty catalog.
useAchievements(mobile/src/hooks/useAchievements.ts) returns only{ achievements, progression, isLoading }and dropsuseQuery'serror/isError, soachievementsfalls back to[]on a network failure, a 401 or a 500.app/achievements.tsxthen renders theachievements-emptybranch — "No achievements yet / Nothing has been published for this account yet — check back soon" — which tells an offline user something false. There is no retry control on this screen. (Favorites, by contrast, has an explicitfavorites-errorbranch with a Try-again button.) - XP is flat. Every achievement pays the same 10 XP regardless of family or difficulty —
seed-achievements.ts:117hardcodesxpValue: 10for every seeded row (though the admin PATCH endpoint allows changing an individual row'sxpValueafter seeding). - Streaks are patch-activity-only. Opening the app or earning an app-family achievement on consecutive days does NOT advance
currentStreak/longestStreak— only a real patch collection does (achievements.service.ts:152-166, explicit design note against letting the five Habit achievements be earned by app-opens alone). - A user with a huge existing patch history and zero prior achievements (i.e., the feature launching against existing users) gets every qualifying achievement written and paid for on their very first evaluation, but none are "announced" (no unlock-deck cards) — see the retroactive-seed suppression described in "How it works," step 7.
- Approving an achievement is retroactive and can grant it to many users at once on their next sync/feature-event — there's no batching or rate-limiting of the resulting award writes beyond what a single
award()call already does per user. - Deleting an achievement in the admin (
DELETE /:id) cascades and destroys everyUserAchievementrow referencing it — earned history is lost, not just hidden. The admin UI defaults to deactivating instead.
What this feature does NOT do
- It does not currently reward anything for camera-roll/album-imported patches — achievements exist specifically to reward being physically present ("in-person earning"), enforced by filtering to
source: 'gps'. - A fresh seed does not ship "on": all 144 achievements are unapproved by default, and nothing is earnable until an admin approves rows and publishes. This is a property of the seed, not the current prod state — achievements ARE approved and live in production today.
- It does not use per-family secrecy/redaction anymore. The old "secret" concept — where an unearned secret achievement was served to the client stripped of name/description/steps — is gone. All 144 achievements, including the 49 in the
secretfamily (relabeled "Wild Cards" in the UI), are sent to the client in full at all times;isSecretis now purely a display label, not an access-control boundary. - It does not support achievement tiers/levels beyond a single unlock — each row is a single boolean earn, not a bronze/silver/gold progression (no
tierfield is populated or read; the fixture carries atierkey but the seed script does not consume it). - It does not revoke achievements. There is no code path that removes a
UserAchievementrow for a still-existing achievement (short of deleting the achievement itself or a full account-data wipe). - It does not use a generic rules engine for the
secret/Wild-Card andbehaviorfamilies — those 49 + 5 rules are hand-written TypeScript predicates keyed by slug, not data-driven from themetriccolumn the waytype/volume/geo/appare. - It does not compute location-, time-of-sunrise/sunset-, or photo-EXIF-based achievements yet — 11+ secret achievements that would need that data are deliberately left dormant (see Edge cases) rather than approximated.
- It does not gate itself behind any entry in the feature-flag system used elsewhere in the app (
backend/src/feature-flags, mobileFeatureFlags) — searching both for "achievement" returns no matches. Do not describe it as "flag-gated"; it is content-gated per row.
Tests that cover it
Backend (backend/src/achievements/__tests__/ unless noted):
achievement-evaluator.service.spec.ts,achievement-evaluator.app.spec.ts— per-family evaluation logic (unit, no DB).achievement-rules.spec.ts—isUnearnable,isCompoundDifficulty,constrainingTypeunit tests.achievement-rules.live.spec.ts(top-level, not__tests__/) — drift audit against the real catalog/2,155-patch database (npm run test:live, excluded from CI): unearnable-threshold and compound-difficulty checks,RARE_SUPPLY_CEILINGboundary behavior.achievement-rules-compiled-layout.api.spec.ts— regression test for thefindRepoRoot()compiled-vs-source path bug described inachievement-rules.ts's docblock.achievements.controller.spec.ts,achievements.controller.db.spec.ts—GET /api/achievements, lazy-seed-if-never-evaluated behavior, empty-progression-for-no-activity.achievements.service.db.spec.ts,achievements.service.app.db.spec.ts,achievements.patch-source.db.spec.ts—award()end-to-end against a real DB, including the concurrency/race regression tests (duplicate-insert race, XP-loss race) and thesource: 'gps'in-person filter.achievement-deeplinks.db.spec.ts— validates every seededdeepLinkagainst the real mobile route registry.achievement-approval.db.spec.ts—isApprovedgating and retroactive-grant behavior.achievement-art.db.spec.ts,seed-achievements.db.spec.ts— art manifest / seed-script correctness.backend/src/sync/__tests__/sync.service.achievements.spec.ts,sync.achievements.db.spec.ts— the sync-push award path.backend/src/admin/api/__tests__/achievements-api.spec.ts,achievements-api.immutable-rule.db.spec.ts,achievements-api.partial-update.db.spec.ts— admin CRUD/validation/immutable-rule-field enforcement.backend/admin-ui/src/**/*.test.ts(x)—achievementRules.test.ts,AchievementsPage.approval.test.ts,AchievementEditorForm.test.tsx,AchievementListRow.test.tsx,DeleteAchievementDialog.test.tsx,AchievementPreview.test.tsx,AchievementEditorActions.test.tsx.
Mobile:
mobile/screen-tests/achievements.test.tsx— the/achievementsscreen (registered in the screen-test registry).mobile/screen-tests/screen-mocks.test.tsx— mounts all tensrc/dev/mocks/achievements.tsxstates and asserts a per-state testID/text, so a state that silently renders blank fails the build. It also enforces that the mock renders the screen's own layout rather than redrawing it, and that no view-model field is typed as a rendered node.mobile/src/domain/__tests__/achievementQueue.test.ts— the unlock-deck queue store logic.mobile/src/components/achievements/__tests__/AchievementSheet.test.tsx,AchievementDeck.test.tsx.
Open questions
Whether any achievements have been approved in production.Resolved 2026-08-31: achievements are approved and live in prod, confirmed by the product owner. The code-level default (isApproved: false) describes a fresh seed only. How many of the 144 are approved, and whether any family is held back, is still unverified from code alone.- The fixture (
mobile/research/js/achievements-fixtures.js) carries atierfield on each row (seen in the sample "Park Life" entry) thatseed-achievements.tsdoes not read into theAchievementmodel at all — unclear whether tiering is a planned future dimension or dead fixture data. - Exact current wording/count of
KNOWN_FEATURE_EVENTSvs. how many are actually wired to real UI call sites was not fully cross-checked screen-by-screen; the list itself (feature-events.controller.ts:34-48) is authoritative for what the server accepts, but whether every listed token has a live client call site was not verified here (the file's own comments note at least 3 tokens —collection_pinned>=1,collection_hidden>=1,collections_sort_changed— belong to now-retired achievements but are kept live for compatibility).