One React codebase is the website and, wrapped by Capacitor, the iOS and Android apps.
| Concern | Choice |
|---|---|
| UI | React 19, plain CSS on design tokens (night and day themes) |
| Routing | TanStack Router; every screen is a lazily loaded chunk |
| Local data | Dexie over IndexedDB, with live queries driving the UI |
| Build | Vite; a separate native build uses absolute API and content URLs |
| Offline (web) | Service worker (Workbox) precaches every asset |
| Native | Capacitor 8: push, haptics, app lifecycle, store links |
| Errors | Sentry (browser SDK, loaded lazily); Core Web Vitals to first-party analytics |
erDiagram
keeps ||--o{ reviews : "card log"
seen ||--o{ attempts : "per box"
decks }o--o{ keeps : "lists boxes"
outbox }o--|| keeps : "queued writes"
keeps {
string id PK "ULID"
string boxId
int version
number createdAt
number removedAt
}
reviews {
string id PK
string cardId
string boxId
int rating
string source
number reviewedAt
}
seen {
string boxId PK
number firstSeenAt
number lastSeenAt
int deepestLayer
}
attempts {
string id PK
string boxId
bool correct
int confidence
number at
}
decks {
string id PK
string name
string boxIds "list"
string topicId
number updatedAt
bool deleted
}
outbox {
string id PK "ULID"
string type
string payload "JSON"
number createdAt
}
Other tables: kv (settings, sync state, the last verified manifest, cached content files), bundles (verified box bundles) and puzzles (first finish of each daily puzzle).
decision Card state is never stored. It is replayed from
keeps,attemptsandreviewswhenever needed (see Learning Engine). Two devices merge by taking the union of their logs: no conflicts to resolve.
sequenceDiagram
participant U as Learner
participant UI as React UI
participant DB as IndexedDB
participant S as Sync engine
U->>UI: taps Keep
UI->>DB: one transaction: keeps.add + outbox.add (ULID)
DB-->>UI: live query re-renders Shelf, Review, Maps
S->>DB: next batch, user-data lane first
S->>S: push in the background (retry with backoff)
Writes never wait for the network. The outbox has two lanes: user data (keeps, reviews, attempts, settings, decks) always drains before analytics, so a pile of events can never delay someone's progress.
app_started health event: cold-start time, content time, and whether content came from the network or the device.The first download is about 133 KB gzipped. Everything else loads on demand:
The service worker precaches every chunk, so on-demand loading still works offline on the web; on phones the files are local.
stateDiagram-v2
direction LR
[*] --> Current
Current --> Banner: build < latest
Banner --> Current: update / later (3 days)
Current --> Blocked: build < min
Blocked --> Current: updated
note right of Blocked: "Time for an update"<br/>signed min in release info
| Platform | How a new version arrives |
|---|---|
| Web | The service worker checks hourly. A version that installs before the first tap or scroll applies at once (guarded against reload loops); otherwise "A fresh version is ready" offers a reload. A signed web.min forces a reload for copies left open on a broken version. |
| iOS / Android | The signed release info carries the latest and minimum build: below latest, a dismissible banner; below min, a blocking screen. After updating, "What's new" appears once. |
Feed · Box · Review · Shelf (decks and kept boxes) · Deck · Shared deck · Play (daily puzzles, labs) · Challenge · Search · Explore · World · Topic · Map (topic and world) · Me (account, reminders, interests, privacy) · Onboarding.
${sch.nav()}