Mobile — Expo Client
demostatics-mobile_application is the native phone client. It is a client of the display
tier, not a system of its own: every screen in it reads or writes demostatics-web over
that project’s /api/v1, and it computes nothing.
What it is
Section titled “What it is”The app and the web UI share one database, one user table, one role set, one ban list and
one forum. A post written on the phone is the same row the web forum renders — there is no
sync step, no mirror and no mobile-only store. demostatics-web is the identity authority;
the phone holds a bearer token issued by it and nothing else.
It deliberately performs no analytics. Heavy statistics, forecasting and simulation belong to the desktop sibling, which runs them locally — see Desktop. The data both clients display is meant to be produced by a worker backend that does not exist in code anywhere, which is why the Data tab is a working surface over rows that only a local seeder has ever written. See Worker Tier.
demostatics-web (Laravel 11) display tier + /api/v1 server of record, only identity authority | +-------------+-------------+ | | demostatics-mobile the web app's own Expo / React Native browser UI (this page)
demostatics-pc_application (Rust desktop) NOT a client of the above. Targets a different, unimplemented contract and talks to its own in-repo mock stream server.
worker tier produces the data both display surfaces show. Not built.Status of each area
Section titled “Status of each area”| Area | Status | Note |
|---|---|---|
| Auth stack: register, login, forgot password, resend verification | Shipped | Password reset (consuming the emailed token) has no screen |
| News — editorial articles, category/subcategory filter | Shipped | |
| Forum — posts, threaded comments, search, sort, community filter | Shipped | Comments are not paginated in the UI |
| Polls — vote, move your vote, live result bars, discussion | Shipped | Create and delete only; no poll editing |
| Data — Reports, Database, seven-dimension filter, date range | Shipped as a surface | The rows it lists have no producer |
| Profile — account, staff presence, moderation board, about/diagnostics | Shipped | |
| Moderation actions (ban, unban) | Not built | The API functions exist in the client; nothing calls them |
| Charts, entitlements, billing, push, device linking, alerts, calculator, settings, map | Planned | Roadmap phases 3–9, no code |
| i18n | Planned | Every string is an inline literal today |
Release config — eas.json, dev client, CI | Not built |
The route tree
Section titled “The route tree”Routing is expo-router: a file under src/app/ is a route. The real tree in
demostatics-mobile_application/src/app/:
| Route | File | What it does |
|---|---|---|
/(auth)/login | (auth)/login.tsx | Email + password, surfaces a ban reason carried over from a failed restore |
/(auth)/register | (auth)/register.tsx | Creates an account; the result is an unverified session |
/(auth)/forgot-password | (auth)/forgot-password.tsx | Requests the reset email |
/(tabs) | (tabs)/index.tsx | News — the editorial feed, and the app’s home. Owns the unverified-account notice |
/(tabs)/forum | (tabs)/forum.tsx | Forum — post list, search, community filter, sort |
/(tabs)/polls | (tabs)/polls.tsx | Polls — poll list with vote counts |
/(tabs)/data | (tabs)/data.tsx | Data — Reports and Database behind a segmented control |
/(tabs)/profile | (tabs)/profile.tsx | Profile — account, verification, staff rosters, navigation |
/post/[id] | post/[id].tsx | Post detail plus the comment thread |
/poll/[id] | poll/[id].tsx | Poll detail, options, result bars, comment thread |
/article/[id] | article/[id].tsx | Editorial article detail |
/compose/post | compose/post.tsx | Modal. Create and edit, keyed off an optional id param |
/compose/poll | compose/poll.tsx | Modal. Create only — 2 to 6 options, 1 to 7 voting days |
/profile/edit | profile/edit.tsx | Name and email |
/profile/password | profile/password.tsx | Current password + new password |
/about | about.tsx | Standing description, About Us articles, and the health diagnostics footer |
/moderation | moderation.tsx | The public moderation board (editor cards) |
+not-found | +not-found.tsx | Unmatched route |
The five tabs are declared in (tabs)/_layout.tsx in product order: what is happening
(News), what people are saying (Forum, Polls), the worker-produced numbers (Data), and you
(Profile).
compose/post.tsx is one screen for two jobs. It reads useLocalSearchParams<{ id?: string }>();
when the param parses to a positive integer the screen fetches the existing post, prefills
once behind a ref so a background refetch cannot overwrite typing, and switches the mutation
from createPost to updatePost. There is no equivalent for polls — the API has no poll
update route.
Tech stack
Section titled “Tech stack”Versions read from demostatics-mobile_application/package.json.
| Package | Version | Role |
|---|---|---|
typescript | ~6.0.3 | strict: true, path alias @/* → src/* |
react | 19.2.3 | |
react-native | 0.86.2 | |
expo | ~57.0.9 | SDK 57 |
expo-router | ~57.0.9 | File-based routing |
@tanstack/react-query | ^5.101.4 | All server state |
zustand | ^5.0.14 | Session state only |
expo-secure-store | ~57.0.1 | Keychain / Keystore token storage |
expo-device | ~57.0.1 | Device name for the token label |
lucide-react-native | ^1.28.0 | Icons |
react-native-web | ~0.21.0 | Development-only web target |
jest / jest-expo | ~29.7.0 / ~57.0.3 | Test runner |
eslint / eslint-config-expo | ^9.0.0 / ~57.0.1 | Flat config in eslint.config.js |
app.json sets scheme: demostatics, com.demostatics.mobile as both the Android package
and the iOS bundle identifier, and turns on two experiments: typedRoutes (route strings
are type-checked against the real tree) and reactCompiler.
How the code is organised
Section titled “How the code is organised”The layering is deliberate and narrow.
One file touches fetch. src/lib/api/client.ts is the only place an HTTP call is made.
It attaches Accept: application/json, adds the bearer token unless the caller passed
anonymous: true, drops empty query values instead of sending ?search=, returns
undefined on 204, and turns every failure into an ApiError. Nothing above it ever sees a
Response object or a raw status code.
One function per endpoint. src/lib/api/index.ts mirrors the contract group by group —
health, auth, forum, comments, polls, reports, database, editorial, geo,
moderation. Callers get resources, not envelopes: the .data unwrapping happens here.
Paginated lists are the exception and return Page<T> whole, because their meta is what
drives infinite scroll.
A hand-maintained type mirror. src/lib/api/types.ts is a TypeScript transcription of
the JSON API. Nothing generates it. When a resource changes on the server it has to be
changed here in the same pass, or the app compiles cleanly against a shape that no longer
exists. This is the single most fragile seam in the repo. See
JSON API v1.
One store. src/stores/session.ts is the only zustand store — status, token, user, ban
reason. Everything else is react-query cache.
One infinite-scroll wrapper. src/hooks/useInfiniteList.ts holds the pagination wiring
once: page 1 upward, meta.last_page decides when to stop, pages flattened for FlatList,
plus isEmpty and a loadMore that no-ops while already fetching.
All cache keys in one file. src/lib/query.ts exports the QueryClient (30s stale
window, refetch on focus, no retry on any 4xx) and every key. Prefixes are hierarchical, so
invalidating ['posts'] covers every filtered feed variant and the detail keys nested under
it.
Auth and session
Section titled “Auth and session”The token is an opaque Sanctum bearer token. It is not a JWT and it carries no claims,
but it is not unbounded: demostatics-web/config/sanctum.php sets
'expiration' => env('SANCTUM_EXPIRATION', 60 * 24 * 30), so a token expires 30 days after
issue by default, and expired rows are pruned daily by sanctum:prune-expired --hours=168.
The client has no refresh path — an expired token surfaces as a 401 and the session
handler drops it. The client stores it under the key demostatics.token.
src/lib/storage.ts puts that key in expo-secure-store — the Keychain on iOS, the
Keystore on Android. expo-secure-store has no web implementation, so on web it falls back
to localStorage; that path only ever runs under expo start --web during development.
Wiring, not importing
Section titled “Wiring, not importing”The HTTP layer never imports the store. Instead the store registers two callbacks at module scope, before any component renders:
setTokenProvider(() => useSession.getState().token)givesclient.tsa read-through to the current token.setUnauthenticatedHandler(...)is called byclient.tson any 401. It drops the stored token and flips the session toguest, so an expired or revoked token cannot leave the app retrying forever against a screen that will never load.
Callbacks rather than imports because the store already imports the client — wiring it the other way round would be a module cycle.
Cold start
Section titled “Cold start”restore() runs once from the root layout:
read demostatics.token | +-- no token ------------------------> status = guest | +-- token present set token, call GET /auth/me | +-- 200 ---------------------> status = authenticated, user = response | +-- ApiError.status === 0 ---> status = authenticated, TOKEN KEPT | (server unreachable) screens show their own offline state | +-- any other error ---------> delete token, status = guest (ban reason captured if banned:true)The status-0 branch is the part worth knowing. A stored token is trusted only as far as
/auth/me, because it may have been revoked server-side by a logout elsewhere, a ban or an
account deletion. But an unreachable server is not a revoked token, so an offline cold start
keeps the session instead of silently signing the user out and losing it.
Token naming
Section titled “Token naming”Tokens are named after the device, so a user could later tell their sessions apart and revoke one without killing the rest:
`${Device.deviceName || Device.modelName} (${Platform.OS})`with Demostatics ${Platform.OS} as the fallback when both are null. The “signed-in
devices” screen that would use this is Planned, not built — see Roadmap.
The three 403s
Section titled “The three 403s”A 403 from /api/v1 can mean three different things, and the client tells them apart from
explicit flags in the body rather than from the status code.
| Cause | Body carries | ApiError surface | What the user sees |
|---|---|---|---|
| Account is banned | banned: true, ban_reason | .banned, .banReason | The reason, on the login screen after the session is dropped |
| Email not verified | email_unverified: true | .emailUnverified, .isUnverified | ”Verify your email” — with resend and re-check actions |
| Policy denial (not your post) | neither flag | plain 403 | The server’s own sentence |
Keying isUnverified off the explicit flag rather than off the status is what stops a
verified user being told to check their inbox when what actually happened was that they do
not own the post they tried to edit.
Ahead of a request, the client uses one predicate:
export function canWrite(user: User | null): boolean { return !!user?.email_verified && !user.is_banned;}That mirrors the API’s verified tier, which in demostatics-web/routes/api.php gates
post/comment/poll writes, voting, /reports, /database-items and the moderation ban
routes. Unverified accounts can read the forum, polls and news, but cannot post, vote or
open Data. That is the API’s rule, not a client-side one — the client only avoids firing a
request whose answer it already knows.
For per-object permissions the client reimplements nothing. Every post, poll and comment
carries a server-computed can block ({ update?, delete? }) derived from the same Laravel
policies the web UI uses, and the screen renders what it is told.
Dimensions and the Data tab
Section titled “Dimensions and the Data tab”Both worker-produced resources extend the same Dimensions interface — seven nullable id
columns:
| Column | Chain |
|---|---|
region_id | geo |
subregion_id | geo |
country_id | geo |
state_id | geo |
city_id | geo |
category_id | classification |
subcategory_id | classification |
src/components/DimensionFilter.tsx drives both chains as collapsible levels, each level
enabled only once its parent is chosen. Picking a level clears every level below it in the
same chain: a country left over from a previous region would otherwise still be sent, AND
with the new region server-side, and silently match nothing. Unset levels are omitted from
the query rather than sent as 0.
The sheet also holds a start_date / end_date range, validated as literal YYYY-MM-DD
(re-formatting the parsed date back to the input string, because Date() rolls 2026-02-31
over into March) with the end required to be on or after the start. It keeps a draft copy of
the selection, so closing without applying leaves the live filters untouched.
Reports vs Database items
Section titled “Reports vs Database items”Report | DatabaseItem | |
|---|---|---|
| Endpoint | /reports | /database-items |
| Distinct fields | summary, published_at | value, summary, measured_at |
| Card shows | Title, summary, dimension tags, publication date | Title, the value in large type, summary, tags, measurement time |
| Access tier | verified | verified |
Cards can only name three of the seven dimensions. Region, category and subcategory have
lookup endpoints reachable from what the row carries. Country, state and city do not: their
endpoints are keyed by a parent id (/subregions/{id}/countries, /countries/{id}/states,
/states/{id}/cities) that the row does not always carry — a row may set country_id with
a null subregion_id. Those levels are left off the card rather than shown as bare numbers.
Running it
Section titled “Running it”The app is useless without the server, so start the server first.
1. Start demostatics-web
Section titled “1. Start demostatics-web”cd demostatics-webcomposer installphp artisan migratephp artisan db:seed --class=DemoSeederphp artisan serveDemoSeeder creates verified demo accounts (admin@demostatics.test,
editor@demostatics.test, member@demostatics.test) all with the password password.
Confirm the API answers:
curl -s -H 'Accept: application/json' http://localhost:8000/api/v1/health{ "status": "ok", "app": "Demostatics", "api": "v1", "world_store": true, "can_register": false, "queue": "requires_worker"}can_register: false means outbound mail is not deliverable (MAIL_MAILER=log or array),
so new accounts can never verify and Data stays locked. queue: "requires_worker" means
queued jobs are accepted and never run. Both are expected in a default local setup.
2. Start the app
Section titled “2. Start the app”npm installnpm start # then press a / i / wOr go straight to a target:
npm run androidnpm run iosnpm run webOn a simulator or on web the defaults resolve on their own: http://localhost:8000
everywhere except the Android emulator, which gets http://10.0.2.2:8000.
3. Physical device
Section titled “3. Physical device”A phone on wifi cannot reach your laptop’s loopback. Bind Laravel to all interfaces and point the app at your machine’s LAN address:
# in demostatics-webphp artisan serve --host=0.0.0.0 --port=8000# in demostatics-mobile_applicationecho 'EXPO_PUBLIC_API_URL=http://192.168.1.42:8000' > .envThe About screen prints the origin the app resolved and whether the geo store is reachable — check there first when a filter renders empty.
4. Checks
Section titled “4. Checks”npm test # jestnpm run typecheck # tsc --noEmitnpm run lint # eslint, including the React Compiler rulesnpm run build:web # static export; catches bundler-level breakagenpm test currently runs 102 tests across 6 suites: the HTTP layer’s mapping of
responses onto typed errors, the session store’s token handling (including the offline case
where the session must survive), the formatters, and three UI atoms. Native modules are
mocked in jest.setup.js.
Full setup for the rest of the platform is on Getting Started.
Known gaps
Section titled “Known gaps”Each of the following was verified against the source, not inferred.
| Gap | Status | Detail |
|---|---|---|
| Four API functions have no caller | Not built | auth.resetPassword, auth.deleteAccount, moderation.ban, moderation.unban exist in src/lib/api/index.ts and appear nowhere else in src/. Forgot-password sends the email; nothing consumes the emailed token |
| Comments are not paginated in the UI | Partial | CommentThread calls api.comments.onPost/onPoll with no page param and renders page 1, while its heading prints meta.total — so a thread of 60 comments says “60 comments” above the first page of them (10 by default, RESULTS_PER_PAGE in demostatics-web/config/demostatics.php) |
| About screen shows one of three health fields | Partial | Only world_store is surfaced. can_register and queue — the two fields that explain a silently broken deployment — are typed in src/lib/api/types.ts and never rendered |
| Country / state / city cannot be named on Data cards | Partial | Their lookups are keyed by a parent the row does not carry |
WEB_ORIGIN is dead | Not built | Exported from src/lib/config.ts, referenced nowhere |
src/global.css is dead | Not built | A :root font-stack block, imported by nothing |
| No release build configuration | Not built | No eas.json, no dev client, no CI workflow of any kind in the repo |
| No i18n | Planned | Roughly 155 user-facing strings, every one an inline literal. Extraction is scheduled for roadmap Phase 5 |
| No entitlements, billing, paywall, push, device linking, alerts, calculator, settings screen or map | Planned | Roadmap phases 3–9. No code exists for any of them |
| No screen or route is tested | Partial | The 102 tests cover the HTTP layer, the session store, the formatters and three UI atoms. No test mounts a screen or exercises a route |