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 unlocked LIVE in the app (GPS-sourced) count toward any patch-derived achievement — not camera-roll imports, not the patch ⋯ menu's Unlock Manually, not an admin grant.
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 land with
isApproved: false— the seed (backend/scripts/seed-achievements.ts) never sets the column, so each row takes the Prisma default@default(false)(backend/prisma/schema.prisma:2197, with the comment at:2194-2196: "Seeded false for all 144 — the app serves only approved ones").AchievementsController.list()(backend/src/achievements/achievements.controller.ts:55-56) filters toisActive: true, isApproved: true(:65). (Citations re-verified 2026-10-29.) 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). - Say Cheese and Going Public follow Post from My Visit (built 2026-10-11,
c9a7dac4/62082888; shipped in v1.1.106, and the backend change is deployed). Visit photos are now posted to a place's board instead of published to its gallery, so both achievements moved with them — see Edge cases. Going Public's description is content: Alan edits it in the prod admin from "Publish a photo to a place's gallery." to "Post a photo to a place's board."; this repo does not change it (the seed fixturemobile/research/js/achievements-fixtures.jsstill carries the old line). is_approvedis one of the published columns for theachievementcontent entity (backend/src/content-publish/content-entities.ts:551-586), 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". One achievement's detail has its own:scout://achievement/<slug>(below). - Drawer entry point:
mobile/src/components/navigation/drawerSections.ts:214—{ 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). Above two queued achievements, the deck collapses to a single summary card instead of playing them one at a time (2026-09-20,AchievementDeck.tsx'squeue.length > 2branch) — a brand-new account can earn six to eightfamily: 'app'achievements just from opening screens during onboarding, and playing each as its own ~3.2s card is a 20+ second backlog before the user does anything (a real case: eight cards before "leaving the parking lot"). TheSummaryCard(testIDachievement-card-summary) states the count as the headline, lists up to three names beneath, holds longer (HOLD_MS + 1200), and on completion or tap calls a newdequeueAllAchievements()that drains the whole queue at once rather than the usual one-at-a-timedequeueAchievement(). Two or fewer queued achievements still play as individualFrontCards, unchanged. Both branches share the deck's existing invariant via a commonuseHoldExitCardhook: nothing is dequeued on mount or on becoming visible, only after a full hold + exit or a tap — the same thing that lets a patch celebration pre-empt the deck without losing a card also protects the summary card. This is the client-side sibling of the backend's ownannounce: falseretroactive-seed suppression above — that one protects a long-history user's first sync from a pile of cards; this one protects a brand-new user's first session from the same thing, by a different mechanism (collapse, not suppress). See onboarding for where this is exercised end-to-end (the reveal screen's "Also unlocked · N achievements" row reads the same queue's length without dequeuing it). - 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 (its own route, a native form sheet):
/achievement/[slug]—mobile/app/achievement/[slug].tsx→mobile/src/screens/AchievementDetailScreen.tsx, deep linkscout://achievement/<slug>. Registered inmobile/app/_layout.tsxaspresentation: 'formSheet'with one full-height detent (sheetAllowedDetents: [1.0]), a grabber and gestures on: swipe down to dismiss, and Android's back closes it (both owned by the native stack). The achievements grid opens it withrouter.push('/achievement/<slug>')(2026-10-02; before that the detail was a full-screen overlay mounted over the grid). (Home's reward pills opened it too, until Home went one-screen on 2026-10-08.) The slug is resolved against the sameGET /api/achievementslist the grid renders (useAchievements), so the server stays the only source of "earned" and an unapproved or retired achievement is simply absent: a slug with no visible row — or the bare[slug]template the DEV deep-link launcher opens — shows an "This achievement isn't available" state with a See all achievements button, never a blank sheet. While the list's first load is in flight it shows a page-loader skeleton of the detail's shape (achievement-detail-skeleton). The body ismobile/src/components/achievements/AchievementSheet.tsx(testIDsachievement-sheet-*), drawn on the unlock celebration's own background,CelebrationBackdrop(mobile/src/components/celebration/CelebrationBackdrop.tsx, the same ember field, vignette, halo and god-rays the legendary reveal uses) — it replaced the Gorhom bottom sheet on 2026-10-02. Earned: the legendary palette with halo + god-rays. Locked: the same field gone cold — grey, dim, no halo or rays — so earning a badge visibly lights it up. No drifting embers (it is read, not celebrated) and no reveal burst (opening a detail is not an unlock). 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 (left-aligned) and an optional Take me there deep-link button pinned to the bottom of the sheet by a flexible spacer (it follows the steps when they run long); it closes the sheet, then opens the link. Both appear only onapp-family badges (since 2026-10-07): those are earned by using a screen, and the button opens it (Compass, Upload from Camera Roll, the trip planner). A place, count, state or habit badge (type,volume,geo,behavior), such as Grass Act "Visit a park.", shows just its art, name, chip and description; its old generic steps and its link to Browse / Near Me / the States map / Collected were removed by migrationbackend/prisma/migrations/20261007120000_achievement_actions_app_only/migration.sql. The sheet itself has no family rule: it hides the steps whenstepsis empty and the button whendeepLinkis null, so this is content, and the seed (unlockActions,backend/scripts/seed-achievements.ts) applies the same rule to fresh rows. The art is62%of the width (capped at 240pt). The content ignores the safe area's top inset (the sheet starts below the status bar) and keeps the bottom one. A close button sits top-left. There is no longer a separate withheld/redacted body for secrets. - Profile screen (
mobile/app/(drawer)/profile.tsx, since 2026-09-28, board ticket 1c035f64): an Achievements section — header withearned / total ›(opens/achievements), up to four earned badges (in catalogsortOrder: the API carries noearnedAt, so these are not necessarily the most recently earned), and a Next up row (the first locked, non-secret achievement in catalog order; since 2026-10-07 it no longer requires adeepLink, which onlyappbadges now carry, so a place badge like Park Life can still be Next up). It has its own error state ("Couldn't load achievements." + Try again) and an empty state ("No badges yet"). The hero's stat line adds "N badges". It shows no level, XP or streak. - 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, photos — patch-gallery photos plus posts with at least one photo, see "Say Cheese" below — 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:93) 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. None of this is shown to users — see "What this feature does NOT do". - 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).
Shared with Missions. The app-metric reader is the exported pure function metricSatisfied (backend/src/achievements/achievement-evaluator.service.ts:35), which qualifiesApp calls, and the context assembly is AchievementsService.buildEarnedContext (backend/src/achievements/achievements.service.ts:21), which award() calls. Both were extracted (behaviour unchanged) so Missions read the exact context achievements are judged against. An achievement that a mission is tied to plays the full-screen Mission complete overlay instead of a deck card, ahead of other queued achievements (mobile/src/components/achievements/AchievementDeck.tsx:98); if the missions list is not loaded it falls back to the ordinary card. See missions.md.
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://...; set only onapp-family rows),steps(JSON array of strings, the "how to unlock" copy; non-empty only onapp-family rows),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, placeType, progress) for every approved+active row, earned or not — no per-family redaction.progress: { current, target } | null(added 2026-10-02 for the new Home) — how far along a countable badge is: patches of itsplaceType(type), all patches (volume), distinct states (geo). It is counted by the evaluator's ownprogress()(achievement-evaluator.service.ts:55), whichqualifies()now reads for those three families, so the number shown and the decision to award are one measurement. It reads the same in-person-only patch list asaward(), viaAchievementsService.inPersonPatches()(achievements.service.ts:143), so camera-roll imports count 0 here exactly as they earn nothing.nullforapp,behaviorandsecret(wild card) achievements, which have no count.currentmay exceedtarget.placeType— thecollection_typeatype-family badge asks for (parkfor Grass Act); null on every other family.
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, loading and empty. Registered inmobile/src/dev/screenMocks.tsx. A cell press is inert in these mocks — the detail is its own route.mobile/src/dev/mocks/achievement.tsx— the detail sheet's entry (scout://dev-screen-mock/achievement-detail?state=<slug>): earned, locked, locked wild card, loading and not-available, on real catalog rows.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:551-586—achievementcontent-entity registration (published columns, natural keyslug). (The block moved down when the plan-tab entities were added on 2026-10-24.)
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 catalog — 3,553 patches live on 2026-10-29 (1,564 of themstate_parkfrom the October expansion; the spec's own header comment still says "2,155", see Open questions) — vianpm 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(backend/src/achievements/feature-events.controller.ts:34-54) 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
-
Reset Progress is a real zero.
award()reads all of history, soresetProgressdeletes the earned rows anduser_feature_events, then records aprogress_resetrow whosefirstAtis the cutoff.buildFeatureCountscounts posts, votes, favorites and the like only from there. No client can post that event, because it isn't inKNOWN_FEATURE_EVENTS. -
Saved trips can bring trip badges back after a reset. The reset deletes the server's trip rows but the device keeps its trips and sends them again, as new rows, so
trip-advisorcan return with nothing new done. Telling those apart would need the trip's original date on the server. -
Date and time-of-day rules use the SERVER's clock, which is UTC (prod droplet
Etc/UTC, checked 2026-09-23).secret-rules.tsreads the date (onDate) and the hour (d.getHours()) on the server, not the user's local time. So a Halloween visit after 8 PM Eastern / 5 PM Pacific is dated November 1 and misses the Halloween rule, and the late-night and early-morning rules ("Night Moves", "Early Bird") fire on UTC hours, which for US users is afternoon or evening. Not fixed; copy must not promise "Halloween night". -
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.) -
Only live unlocks earn. Only
UserPatchrows withsource: 'gps'count toward any patch-derived achievement (type, volume, geo, behavior, most secrets). A patch collected via photo import (source: 'import'), the ⋯ menu's Unlock Manually ('manual', since 2026-10-19 — it wrote'gps'before), an admin "Unlock a patch" grant ('admin'), or predating thesourcecolumn ('unknown') is invisible to the evaluator. Pinned pairwise inachievements.patch-source.db.spec.ts. The full source table is in unlock-and-location. -
"Say Cheese" (
say-cheese, metricpatch_photo_created>=1) counts gallery photos AND photo posts (2026-10-11, not yet deployed).buildFeatureCountssumspatchPhoto.countand the user's posts with a non-emptyimagesarray (backend/src/achievements/feature-context.ts:47-51,:95). The metric key was kept so the rows already earned on prod are not orphaned. It counts any post with a photo — a post from the board composer or on the general board counts as much as a visit photo — and nothing checks the catalog line "Photograph a place after earning its patch". Deleting the post does not take the badge back. -
"Going Public" (
going-public, the client feature eventphoto_published>=1) now fires when a visit photo is recorded as posted —markVisitPhotosPostedin the app store (mobile/src/domain/store.ts:2962-2966), called after a post from the lightbox sheet succeeds. It moved there from the retired gallery publish; the token is unchanged (backend/src/achievements/feature-events.controller.ts:53), so the one prod earn stays. The same action runs when My Visit's board reconcile learns of a post made on another device, so that also records the event (idempotent, so it matters only for a first earn). A post made from the board composer, with no visit photo, does not fire it. The description shown in the app is whatever the prod row says until Alan edits it (see Status). -
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 Achievements screen has no error state — a failed fetch looks like an empty catalog.
useAchievements(mobile/src/hooks/useAchievements.ts) now also returnsisErrorandrefetch(added 2026-09-28 for the Profile section, which does show an error with Try again), butapp/achievements.tsxstill does not read them, so on that screenachievementsfalls 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:93hardcodesxpValue: 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
- A place, count, state or habit badge's sheet does not tell you how to earn it step by step and has no Take me there button (since 2026-10-07). It does not point you to a park, a state or anywhere else: the description ("Visit a park.") is all it says. Only
appbadges, earned by using a screen, keep steps and a button that opens that screen. - It does not reward anything for a patch that was not unlocked live in the app — camera-roll/album imports, the ⋯ menu's Unlock Manually, and admin grants all earn nothing. 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 have a user-facing level, XP or streak system. The server computes all three into
user_progressionon every award (payXp,achievements.service.ts:167;levelForXp/advanceStreak,progression.service.ts:17,44) andGET /api/achievementsreturns them asprogression, but no screen displays any of them.useAchievements()passesprogressionthrough (mobile/src/hooks/useAchievements.ts:32), and its only caller, the Achievements screen, reads justachievementsandisLoading(mobile/app/achievements.tsx:106). On the Achievements screen the only number a user sees is the earned/total count. The new Home shows no badge progress at all since 2026-10-09, and makes no achievements request. (It drew badge rows and "Perfect Ten 8/10" pills until the one-screen layout on 2026-10-08, then a Badges door counting "one visit away" for a day, which became a Patches door.) Do not describe levelling up, XP or streaks as something a user can see or chase. Showing them was considered for the 2026-09-28 profile redesign (board ticket 1c035f64) and deliberately left hidden. - 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 reward a photo post on any condition beyond "it has a photo": Say Cheese does not check that the photo was taken at the place, after the patch, or by the poster, and Going Public does not check the post is still up.
- 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 database, whatever its size (3,553 patches in prod on 2026-10-29; its header comment still says 2,155 — see Open questions) (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.feature-context.db.spec.ts—patch_photo_createdcounts gallery photos, counts a post with a photo, does not count a text-only post, and adds the two together (2026-10-11).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), including that a cell press pushes/achievement/<slug>(by slug, not id).backend/src/achievements/__tests__/seed-achievements.db.spec.ts— onlyappachievements carry steps and a deep link, both in the database after the 2026-10-07 migration (confirmed failing without it) and for a freshly seeded row (unlockActions).mobile/screen-tests/profile.test.tsx— Next up offers a place badge with no deep link (confirmed failing against the old!!a.deepLinkfilter) and never a secret.mobile/screen-tests/achievement-slug.test.tsx— a place badge with no steps and no link renders as name + description with no "How to earn it" and no Take me there. The/achievement/[slug]sheet: the slug resolves the right row (paired earned / locked wild card), an unknown slug and the bare template show the not-available state, the loading skeleton hands off to the detail, close goes back, and Take me there leaves the sheet before opening the link.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/domain/__tests__/featureEventCallSites.test.ts—markVisitPhotosPostedrecordsphoto_published>=1;mobile/src/domain/__tests__/store.postedVisitPhotos.test.ts— marking visit photos posted fires the Going Public event (2026-10-11).mobile/src/components/achievements/__tests__/AchievementSheet.test.tsx,AchievementDeck.test.tsx.
Open questions
- Known stale fixture (noted 2026-10-29, not fixed here): the header comment of
backend/src/achievements/achievement-rules.live.spec.ts:10says the db-tier fixture "holds none of the 2,155 real patches these rules audit". The live catalog is 3,553 patches (1,564state_park) after the October 2026 state-park expansion, and the per-type example counts in the same comment (lighthouse: 3,wild_west: 7) predate it too (the live feed has 63lighthouse). The spec reads real counts at run time so its assertions are not wrong, but the comment is; and the supply picture the "rare type" rule guards (RARE_SUPPLY_CEILING = 15) has shifted —npm run test:livehas not been re-run against the expanded catalog in this pass. 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 (backend/src/achievements/feature-events.controller.ts:34-54) 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).