> ## Documentation Index
> Fetch the complete documentation index at: https://docs.highailabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Achievements & Stats — Behavior Contract

> Every behavior the achievement tracks, streaks, XP, celebrations and lifetime stats must have, and the test that proves each one.

## What this feature is

Achievements turn what a user already does — buying, stashing, favoriting,
ranking, reading notebooks — into badges. There are ten tracks of four tiers
each, from Core Collection and Spending Milestones through Activity Streaks and
Notebook Mastery, with each card carrying a rarity (Common, Rare, Epic,
Legendary) worth XP toward a level and an identity title. Users see them in the
Achievements Hub, on a detail screen where up to five can be pinned to their
profile, in the profile's "Active Quests" strip, and as a celebration that fires
the moment something unlocks. Nothing here is entered by hand: every number is
derived from the user's own orders, stash, collection and notebooks, and the
same derived numbers power the lifetime totals on the Stats tab.

## How to read the contract

Every case ID below is a live test. Cases built during the feature-test programme
carry their ID in the test title, so `grep 'ACH-045'` finds the assertion. A
handful of rows point at coverage that predates the programme; those are cited by
ID and file, and the test is found by its plain-English name in that file.

## The behavior contract

### Earning a badge

| Behavior                                                                                                                                                                 | Verified by      | Tier(s) | Where                                                       |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------- | ----------------------------------------------------------- |
| Core Collection unlocks at 5, 15, 35 and 60 strains and at no count in between, awarding the rarity XP its tier declares                                                 | ACH-001, ACH-002 | A       | `achievements.featuretests.test.ts`                         |
| Spending Milestones unlock at $50, $250, $750 and $2,000 of finalized spend, on exact money values with no float drift, and an order with no total contributes nothing   | ACH-003, ACH-004 | A       | `achievements.featuretests.test.ts`                         |
| Order Milestones unlock at 3, 10, 20 and 35 orders and count only finalized orders — a draft never counts                                                                | ACH-005          | A       | `achievements.featuretests.test.ts`                         |
| Dispensary Explorer unlocks at 2, 4, 6 and 8 distinct shops, where casing, whitespace and punctuation differences are the same shop, and a blank shop name is not a shop | ACH-006, ACH-008 | A       | `achievements.featuretests.test.ts`                         |
| Deleting an order gives back the dispensary it contributed, so the distinct-shop count falls again                                                                       | ACH-007          | A       | `achievements.featuretests.test.ts`                         |
| Strain Variety measures the *least*-represented of sativa, indica and hybrid, unlocking at 1, 3, 10 and 20                                                               | ACH-009          | A       | `achievements.featuretests.test.ts`                         |
| Changing a strain's type moves the count one down and one up; emptying a type regresses the metric but never revokes a badge already earned                              | ACH-010, ACH-011 | A       | `achievements.featuretests.test.ts`                         |
| Activity Streaks unlock at 7, 14, 30 and 60 consecutive days                                                                                                             | ACH-012          | A       | `achievements.featuretests.test.ts`                         |
| Collection Curator unlocks at 5, 15, 30 and 50 favorites                                                                                                                 | ACH-013          | A       | `achievements.featuretests.test.ts`                         |
| Stash Manager unlocks at 5, 15, 30 and 50 stash items, and deleting an item decrements                                                                                   | ACH-014          | A       | `achievements.featuretests.test.ts`                         |
| Notebook Mastery unlocks at 3, 10, 25 and 50 notebooks viewed, counting each notebook once however many times it is opened                                               | ACH-015          | A       | `achievements.featuretests.test.ts`                         |
| The Daily Stories track's thresholds are defined and correct, but the track is quarantined and no product path can reach it                                              | ACH-016, ACH-017 | A       | `achievements.featuretests.test.ts`, `achievements.test.ts` |
| The Hub hides the quarantined track entirely, so 100% completion is reachable rather than permanently capped                                                             | ACH-018          | B       | `AchievementsHub.test.tsx`                                  |

### Activity streaks

| Behavior                                                                                                                                       | Verified by      | Tier(s) | Where                               |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ----------------------------------- |
| Activity on a day that sits between two runs merges them into one longer streak                                                                | ACH-020          | A       | `achievements.featuretests.test.ts` |
| Activity on the day before or after a run extends that run rather than starting a new one                                                      | ACH-021          | A       | `achievements.featuretests.test.ts` |
| Removing the activity that created a middle day splits the run in two; removing an edge day or a lone day shortens or clears it                | ACH-022, ACH-023 | A       | `achievements.featuretests.test.ts` |
| Several actions on one day count as one streak day, and removing one of them does not end the streak                                           | ACH-024          | A       | `achievements.featuretests.test.ts` |
| A morning and an evening action on the same **Central** day are one streak day, not two                                                        | ACH-026          | A       | `achievements.featuretests.test.ts` |
| The longest streak is a high-water mark in both directions — deleting history never inflates it and never silently rewrites it                 | ACH-025          | A       | `achievements.featuretests.test.ts` |
| Streaks cross month, year and leap-day boundaries without a gap, and a daylight-saving transition introduces neither a gap nor a duplicate day | ACH-027, ACH-028 | A       | `achievements.featuretests.test.ts` |
| A malformed day key is rejected rather than silently shifting a streak                                                                         | ACH-029          | A       | `achievements.featuretests.test.ts` |
| A story-view streak counts the server's day, not the moment the row happened to be written                                                     | ACH-030          | A       | `achievements.featuretests.test.ts` |

### Unlocking exactly once

| Behavior                                                                                                                                                 | Verified by       | Tier(s) | Where                                                             |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------- | ----------------------------------------------------------------- |
| Running the engine repeatedly over unchanged data awards nothing new                                                                                     | ACH-040           | A       | `achievements.test.ts`                                            |
| The engine does no work when it has already evaluated the current revision of a user's numbers                                                           | ACH-041           | A       | `achievements.featuretests.part2.test.ts`                         |
| A user whose numbers are not yet materialized is never evaluated, so no badge is awarded from a partial picture                                          | ACH-042, ACH-054  | A       | `achievements.featuretests.part2.test.ts`                         |
| Every badge unlocked in one evaluation shares a batch id and an unlock time, which is what makes it a single celebration                                 | ACH-043           | A       | `achievements.featuretests.part2.test.ts`                         |
| A bulk import produces one celebration batch, not one per row, and a long serial burst arms exactly one evaluation while still evaluating every revision | ACH-052, ACH-052c | A       | `achievements.featuretests.part2.test.ts`                         |
| Rapid concurrent increments coalesce into one evaluation, and two engine runs in flight never double-award                                               | ACH-050, ACH-051  | A       | `achievements.featuretests.part2.test.ts`                         |
| XP is never revoked: a badge survives deletion of the data that earned it, and re-earning it awards nothing a second time                                | ACH-048, ACH-049  | A       | `achievements.test.ts`, `achievements.featuretests.part2.test.ts` |
| A level change is reported only when the level actually moves, and the level curve is exact at every boundary from 1 to 25                               | ACH-044, ACH-045  | A       | `achievements.featuretests.part2.test.ts`                         |
| A user with no profile gets one created on their first unlock                                                                                            | ACH-046           | A       | `achievements.featuretests.part2.test.ts`                         |
| The profile's metric snapshot is written when the numbers changed and skipped when they are identical                                                    | ACH-047           | A       | `achievements.featuretests.part2.test.ts`                         |
| A projection that would go negative fails loudly inside the write rather than persisting an impossible count                                             | ACH-053           | A       | `achievements.featuretests.part2.test.ts`                         |

### Derived numbers always reconcile with source data

| Behavior                                                                                                                                                                                                                                      | Verified by          | Tier(s) | Where                     |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------- | ------------------------- |
| Every rewritten lifetime query answers identically whether it reads the projection or recomputes from source — the two paths are compared over the same settled data                                                                          | STATS-001            | A       | `statsProjection.test.ts` |
| The projection reports exactly what the underlying rows imply: drafts count as orders but not as spend, an untyped line joins the distinct-strain union without landing in a type bucket, and an untrackable unit is a purchase of zero grams | STATS-002            | A       | `statsProjection.test.ts` |
| Reading lifetime numbers never scans the source tables — 1,200 rows inserted behind the triggers move nothing a read returns                                                                                                                  | STATS-003            | A       | `statsProjection.test.ts` |
| A heavy user's hundreds of source documents compress into a handful of roll-up rows without losing a number                                                                                                                                   | STATS-004            | A       | `statsProjection.test.ts` |
| Deleting an order reverses every counter exactly, including the earliest- and largest-purchase values a plain counter cannot recover                                                                                                          | STATS-005            | A       | `statsProjection.test.ts` |
| Un-finalizing and re-finalizing an order round-trips to the same numbers, and adding a line to an existing order updates its shop roll-up instead of emptying it                                                                              | STATS-006, STATS-011 | A       | `statsProjection.test.ts` |
| Renaming a shop moves its whole roll-up — spend and gram totals together                                                                                                                                                                      | STATS-007            | A       | `statsProjection.test.ts` |
| A user whose projection does not exist yet still gets correct numbers, computed from source, rather than confident zeroes                                                                                                                     | STATS-008            | A       | `statsProjection.test.ts` |
| One user's roll-ups are never visible to another                                                                                                                                                                                              | STATS-009            | A       | `statsProjection.test.ts` |
| Cent-precise amounts that cancel out settle to a clean zero rather than throwing on floating-point residue, in either direction                                                                                                               | STATS-014            | A       | `statsProjection.test.ts` |
| A forty-line import writes one history event and forty counted lines                                                                                                                                                                          | STATS-015            | A       | `statsProjection.test.ts` |
| A source write after the queue has fully drained re-arms exactly one evaluation                                                                                                                                                               | STATS-012            | A       | `statsProjection.test.ts` |

### Backfilling a user whose history predates the projection

| Behavior                                                                                                                                                   | Verified by               | Tier(s) | Where                                             |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------- | ------------------------------------------------- |
| A full backfill replays every stage with pagination and finishes only if it matches a full-history recount                                                 | ACH-060, STATS-010        | A       | `achievements.test.ts`, `statsProjection.test.ts` |
| A legacy user's first write materializes their real totals rather than starting them from that one write                                                   | ACH-061                   | A       | `achievements.featuretests.part2.test.ts`         |
| A backfill refuses to start for a user who is already materialized, resumes an interrupted job from its cursor, and refuses a job that has already drifted | ACH-062, ACH-063, ACH-064 | A       | `achievements.featuretests.part2.test.ts`         |
| When the replayed numbers disagree with the recount, the job is parked as drifted instead of publishing a wrong total                                      | ACH-065                   | A       | `achievements.featuretests.part2.test.ts`         |
| A successful backfill schedules an evaluation so the badges the user had already earned appear                                                             | ACH-066                   | A       | `achievements.featuretests.part2.test.ts`         |
| Replaying a backfill counts nothing twice, and it counts only finalized orders                                                                             | ACH-067, ACH-068          | A       | `achievements.featuretests.part2.test.ts`         |
| The one-shot V1→V2 migration is safe to run again and pages rather than collecting whole tables                                                            | ACH-069                   | A       | `achievements.featuretests.part2.test.ts`         |
| Re-keying collapsed dispensary references merges the shops that were duplicates and reclaims the distinct-shop count                                       | STATS-013                 | A       | `statsProjection.test.ts`                         |

### Who can see and change what

| Behavior                                                                                                                                     | Verified by      | Tier(s) | Where                                                                   |
| -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ----------------------------------------------------------------------- |
| Every achievement query rejects a caller whose owner key does not match the signed-in user, and rejects an unauthenticated caller outright   | ACH-080, ACH-081 | A       | `achievements.featuretests.part2.test.ts`                               |
| There is no client-writable unlock surface — a badge cannot be granted from a client, only earned                                            | ACH-082          | A       | `achievements.featuretests.part2.test.ts`                               |
| Pinning a badge requires a matching owner key, so a pin landing after an account switch cannot overwrite the new account's pinned list       | ACH-084, ACH-085 | A       | `achievementUnlocks.test.ts`, `achievements.featuretests.part2.test.ts` |
| The pinned list caps at five, de-duplicates, drops ids the user has not unlocked, and the dashboard filters stale pins out of older profiles | ACH-086, ACH-087 | A       | `achievementUnlocks.test.ts`                                            |
| One user's unlocks never appear for another                                                                                                  | ACH-091          | A       | `achievements.featuretests.part2.test.ts`                               |
| Deleting an account purges all nine achievement tables                                                                                       | ACH-088          | A       | `achievements.featuretests.part2.test.ts`                               |
| A data export contains the user's profile and unlocks but not the internal projection                                                        | ACH-089          | A       | `achievements.featuretests.part2.test.ts`                               |

### Reading achievements in the app

| Behavior                                                                                                                                                | Verified by      | Tier(s) | Where                                                                 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | --------------------------------------------------------------------- |
| The hooks send exactly the arguments each deployed validator declares                                                                                   | ACH-100          | B       | `useAchievementsV2.featuretests.test.tsx`                             |
| The XP and level shown are the server's, never a client recomputation from unlock rows                                                                  | ACH-105          | B       | `useAchievementsV2.featuretests.test.tsx`                             |
| A gated or disabled achievements view returns a zero result without subscribing, and renders the mock dashboard behind the access gate                  | ACH-112, ACH-113 | B       | `useAchievementsV2.featuretests.test.tsx`, `AchievementsHub.test.tsx` |
| The screen distinguishes loading, offline, empty, error, gated and ready — six states, not two                                                          | ACH-101          | B       | `achievementOfflineState.test.ts`, `achievementQueryState.test.ts`    |
| Switching accounts and back never shows the first account's values, and a cold offline launch is visibly different from "locked with zero progress"     | ACH-102, ACH-103 | B       | `AchievementsColdOffline.test.tsx`                                    |
| Legacy unlock rows are filtered out of the V2 view                                                                                                      | ACH-104          | B       | `achievements-utils.test.ts`                                          |
| Progress reads at most 99.9% while locked and exactly 100% only once unlocked, and each track's progress comes from its own metric with no cross-wiring | ACH-110, ACH-111 | B       | `AchievementsHub.test.tsx`                                            |
| The identity title resolves for every track × rarity rule with both fallbacks, breaking an XP tie by the most recent unlock                             | ACH-106, ACH-107 | B       | `achievements-v2.featuretests.test.ts`                                |
| "Almost there" nudges start at 70% of a threshold and stop once it is met                                                                               | ACH-109          | B       | `achievements-v2.featuretests.test.ts`                                |
| Active Quests shows at most three nearest badges and never one with zero progress                                                                       | ACH-108          | B       | `achievements-v2.config.test.ts`                                      |
| The Hub's layout, header count, filters and enlarged-text rendering hold, and a detail screen for an unknown badge id degrades gracefully               | ACH-114, ACH-115 | B       | `AchievementsHubLayout.test.tsx`, `AchievementsColdOffline.test.tsx`  |

### Celebrating an unlock

| Behavior                                                                                                                                                      | Verified by      | Tier(s) | Where                              |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------- | ---------------------------------- |
| One new badge is a toast, two or three a modal, four or more a batch                                                                                          | ACH-120          | B       | `celebration.helpers.test.ts`      |
| The first run after install marks everything as already seen and replays nothing                                                                              | ACH-121          | B       | `AchievementsCelebration.test.tsx` |
| Dismissing a celebration remembers the newest badge it covered, and it stays dismissed across a remount                                                       | ACH-122          | B       | `AchievementsCelebration.test.tsx` |
| Rapid unlocks are debounced into one surface rather than a queue of popups                                                                                    | ACH-123          | B       | `AchievementsCelebration.test.tsx` |
| Once the unlock window is saturated the count is shown as partial rather than as an exact number                                                              | ACH-124          | B       | `AchievementsCelebration.test.tsx` |
| An unrecognized badge id in the feed is skipped without crashing or wedging the celebration host                                                              | ACH-125          | B       | `AchievementsCelebration.test.tsx` |
| A celebration that arrives while a native sheet is open is held and played afterwards, not dropped                                                            | ACH-126          | B       | `AchievementsCelebration.test.tsx` |
| A second engine run sharing the first run's timestamp is still celebrated, not swallowed                                                                      | ACH-130          | B       | `AchievementsCelebration.test.tsx` |
| A failed pin leaves the sheet open to retry rather than closing on an error                                                                                   | ACH-129          | B       | `AchievementsCelebration.test.tsx` |
| Switching accounts clears in-flight celebration state before anything paints, and the pinned-badge query is subscribed only while a modal is actually visible | ACH-127, ACH-128 | B       | `CelebrationHost.test.tsx`         |
| Toast, modal and batch all render every rarity and survive long copy                                                                                          | ACH-131          | B       | `AchievementsCelebration.test.tsx` |

## What the live tier proves end-to-end

Tier C runs against the deployed staging backend. Because thresholds are one-shot,
each case uses a **brand-new, really-signed-in identity** and deletes it afterwards.

* A real order placed through the deployed backend produces a real unlock (**ACH-140**).
* A spending threshold crosses on cent-precise real money with no float drift, and the
  teardown that reverses it settles to a clean zero (**ACH-141**).
* Shops whose names differ only by casing, whitespace or trailing punctuation collapse
  to one dispensary under the production identity rules (**ACH-142**).
* Five real stash writes unlock the first Stash Manager tier and open a one-day streak
  (**ACH-143**); three distinct notebook views unlock the first Notebook Mastery tier
  while a repeat view is ignored (**ACH-145**).
* All five public achievement functions accept the literal payloads the app sends
  (**ACH-146**), and deleting the source orders leaves the unlock standing (**ACH-147**).
* The celebration feed comes back newest-first with one shared batch id per engine run,
  and a pin round-trips and normalizes (**ACH-148**, **ACH-149**).
* Ten parallel writes yield one consistent evaluation, not ten (**ACH-151**).
* No client-writable unlock surface exists on the deployed backend, and the deployed
  validator rejects a pin without an owner key (**ACH-154**, **ACH-085C**).

## Deliberate gaps & conditions

* **The Daily Stories track is a deliberate product quarantine, not a bug.** All ten
  tracks remain defined in the rules so no earned badge is ever rewritten, but there is
  no story producer, the feature flag is hard-disabled, and the track is excluded from
  the visible set — nine tracks and thirty-six cards. ACH-016 proves the thresholds are
  correct; ACH-017 proves the product path cannot reach them; ACH-018 proves the Hub
  hides the track so completion is not permanently capped.
* **There is no steady-state drift detector.** ACH-070 is blocked because the thing it
  would test does not exist. Drift is caught at backfill time (ACH-065) and by the
  differential in STATS-001, not by a scheduled reconciliation.
* **The backfill has no client-callable entry point**, so ACH-152 cannot run a real
  backfill against staging. Backfill correctness is proved at Tier A instead
  (ACH-060 – ACH-069, STATS-010).
* **The recent-unlocks limit clamp** landed after ACH-090 was written; the row is
  buildable now and is the one remaining `deferred` case in this area.
* **Engine latency has no budget test** (ACH-150). Asserting a p95 would need ten
  repeat runs on a fresh identity each time, and a flaky timing gate is worse signal
  than none.
* **Cross-feature chains are proved once, in the owning suite.** The order → stash →
  achievement chain and account-deletion-clears-achievements are asserted in the orders
  and users integration suites rather than duplicated here (ACH-144, ACH-153).
* **Tier C skips cleanly without credentials**, naming exactly what is missing, and is
  never part of `pnpm test`, `pnpm check`, or a cached Turbo run.

## Where to extend

A new track, threshold, streak rule, or projection counter gets its Tier A case in
`apps/mobile/convex/tests/achievements.featuretests.test.ts` (metrics and streaks),
`achievements.featuretests.part2.test.ts` (engine, backfill, auth), or
`apps/mobile/convex/tests/statsProjection.test.ts` (anything a lifetime number depends
on). A new hook argument or rendered state goes in the co-located
`useAchievementsV2.featuretests.test.tsx` / `achievements-v2.featuretests.test.ts`, or in
`apps/mobile/test/ui/features/AchievementsHub.test.tsx` /
`AchievementsCelebration.test.tsx`. Reach for
`apps/mobile/test/integration/suites/achievements.integration.test.ts` only when the
claim is about the **deployed** backend — and remember every threshold there needs a
fresh identity. Then add the row to the master matrix; see
[Mobile Feature Test Platform](/planning/testing/feature-test-platform) for naming,
tags, and the tier decision.
