# ClassicsLens Mobile — CLAUDE.md

Expo / React Native app. Offline-first; syncs to classicslens.com when online.

## Commands

```bash
npm start              # Expo dev server
npm run ios            # iOS simulator (macOS + Xcode)
npm run android        # Android emulator
npx tsc --noEmit       # Type check
npx eas build --platform all  # EAS production build
```

## Architecture

```
expo-router (file-based routing)
  app/_layout.tsx          Root: QueryClient + GestureHandler + SQLite init.
                           Seeds passage bundle on first launch. On first login,
                           syncs new server passages via /api/mobile/passages.
  app/(tabs)/
    index.tsx              Today — daily passage (offline fallback to bundle)
    review.tsx             SRS review (SM-2, 100% offline)
    analyze.tsx            Word parser (online; offline via parse_cache)
    settings.tsx           User prefs, sync to server when online
  app/read/[id].tsx        Reading view — tap words to parse.
                           On load (online): background prefetch stores parse
                           results, full definitions, and paradigm tables for
                           every word in the passage. All available offline
                           after a single online read.
src/
  api/client.ts            Fetch → classicslens.com, X-Session-Id auth header
  api/parseAdapter.ts      Maps server ParseResponse → mobile ParseResult[]
  api/passageAdapter.ts    Maps server PassagePayload → mobile Passage
                           (server: sourceAuthor/rawText → mobile: author/passageText)
  db/local.ts              expo-sqlite: passages, SRS cards, parse cache,
                           paradigm cache, settings
  db/seed.ts               First-launch seeder: loads assets/data/passages.pack
                           (.pack extension so Metro treats it as opaque binary)
  lib/sm2.ts               SM-2 algorithm (offline)
  lib/auth.ts              SecureStore helpers
  hooks/useSync.ts         Pushes dirty SRS cards to server when online
  screens/ParadigmSheet.tsx Bottom sheet: full conjugation/declension tables.
                           ParadigmContent (named export, no Modal wrapper)
                           used inline inside ParseModal to avoid iOS nested
                           Modal limitation.
  types/index.ts           Mobile types: Passage, ParseResult, SrsCard, ...
  theme.ts                 Colors, typography, spacing
  constants.ts             API_BASE_URL, DB names, keys
```

## Passage bundle

`assets/data/passages.pack` — ~42 MB, ~19,000 passages in mobile field format.
`.pack` extension prevents Metro from inlining it into the JS bundle.

Seeded into SQLite at first launch before the user logs in — full corpus
available offline immediately. On first login the app also calls
`GET /api/mobile/passages` to pull passages added to the server after the
bundle was built (`INSERT OR IGNORE` — bundle rows are never overwritten).

Bundle format (mirrors `db/seed.ts BundledPassage`):
```json
{ "id": "...", "language": "latin", "author": "Caesar",
  "work": "Dē Bellō Gallicō", "reference": "I.1",
  "passageText": "Gallia est...", "translationText": "...",
  "wordCount": 23, "contextNote": "...", "thematicTags": [] }
```

## Offline strategy

| Feature | Offline | Online |
|---------|---------|--------|
| Full passage library (19k) | ✅ Bundled at install | Fresh from server |
| Today's passage | ✅ Bundle + day rotation | Server-assigned |
| Word parse + full def | ✅ After one online read | Full morphology engine |
| Paradigm tables | ✅ After one online read | Full paradigm DB |
| SRS review | ✅ 100% local SM-2 | Synced in background |
| Analyze tab (free-type) | Cached words only | Full morphology engine |
| Settings | ✅ Local SQLite | Synced to server |

### Passage prefetch (read/[id].tsx)

When a passage loads while online, a background `useEffect` fires that:

1. Extracts every unique word form from the passage text
2. Fetches the parse result for each (skips forms already in `parse_cache`)
3. From each parse result, collects the unique lemmas returned
4. Fetches and stores the paradigm table for each unique lemma
   (skips lemmas already in `paradigm_cache`)

After reading a passage online once, a user can go fully offline and still:
- Tap any word → see parse, morphology, short def, full LSJ/L&S definition
- Tap Paradigm → see the complete conjugation or declension table

The prefetch is cancellable on unmount, deduplicates lemmas across words,
and re-uses `parse_cache` hits instead of making redundant network calls.

### Cache tables (SQLite)

| Table | Key | Stores |
|-------|-----|--------|
| `passages_cache` | `id` | Full passage text + metadata |
| `parse_cache` | `form` | Raw server parse response (includes fullDef) |
| `paradigm_cache` | `(lemma, language)` | Raw server paradigm response |
| `srs_cards` | `lemma_id` | SRS deck, dirty flag for background sync |
| `settings` | `key` | Key/value store |

## Auth

Session token stored in `expo-secure-store` under `AUTH_TOKEN_KEY`.
Sent as `X-Session-Id` header (iOS doesn't surface `Set-Cookie` to JS).
Server signs session IDs with HMAC-SHA256 (`signSid()` in `server/auth.ts`).

**Do not change `subscription_tier` in the database.** Tier is always
determined server-side by `getEffectiveTier(userId)` in `server/billing.ts`.

## Server field name mapping

| Server (`PassagePayload.passage`) | Mobile (`Passage`) |
|-----------------------------------|--------------------|
| `sourceAuthor` | `author` |
| `sourceWork` | `work` |
| `sourceReference` | `reference` |
| `rawText` | `passageText` |
| `context.contextNote` | `contextCard.contextNote` |

`src/api/passageAdapter.ts` handles this at the API boundary. Accepts both
server `PassagePayload` format and already-mapped mobile format transparently.

## iOS layout gotchas (Yoga engine)

- `flex: 1` on `ScrollView` requires the parent to have an **explicit pixel
  height** — `maxHeight` alone is not enough. Use
  `useWindowDimensions().height * 0.88` to give sheets a concrete size.
- A `Pressable` wrapper around `ScrollView` competes with the pan gesture on
  iOS. Sheet containers are plain `View`; backdrops are `flex: 1` Pressables
  sitting above the sheet in column flow.
- A second `Modal` opened while another is visible won't appear on iOS. Fix:
  switch content inside the same Modal via state (`inParadigm` flag in
  `ParseModal`), using the `ParadigmContent` named export (no Modal wrapper).

## Roadmap

1. Auth flow — email/password + Google OAuth via `expo-auth-session`
2. Push notifications — daily study reminder via `expo-notifications`
3. EAS + TestFlight — first beta build
4. Passage library browse view — list/filter all 19k bundled passages by
   author, language, genre
5. Remove debug `console.log` statements (`client.ts`, legacy parse logs)
