Near-peer mentors and step-by-step roadmaps for first-generation and immigrant high-school students in California. Congressional App Challenge · California.
One claim. Every deadline that decides a first-generation student's future is public information. It is useless without two things: knowing which step is next, and having someone one year ahead who already met it. NextStep is a roadmap of concrete, deadline-bearing steps, plus a near-peer mentor who has verified those same steps, plus a weekly local circle where the work gets done. The home screen shows exactly one step.
| Screen | What it does | Offline |
|---|---|---|
| Next (home) | One next step with a countdown, "N mentors at your school did this", progress per track, up to four coming-up steps, your mentor, your circle, the family view prompt | Yes |
| Roadmap | Every step grouped Overdue / Do now / Next / Later / Done, filtered by track, with "why your roadmap looks different" explained as form choices and goals, never as a status | Yes |
| Step detail | Why it matters, what you need (tickable), how to do it, the official source and resources each with "verified by X on DATE", correction ticket, finish | Yes |
| Finish a step | Four verification paths: mentor confirms, screenshot (private, 90 days), coordinator confirms, self-attest (visibly different) → verified screen, points, badge progress, next step unlocked | Yes (queued) |
| Mentor | Thread with your mentor; guarded composer that refuses phone numbers, addresses, handles, off-platform meetings and money before anything is sent; templates instead of a blank box; report flag in the header | Yes (saved on the phone) |
| Mentor discovery / profile | Fit as a word and bars (never a bare percentage), the six matching terms with their weights, can-help and will-refer-you lists from verified steps | Yes |
| Circles | Recurring meetups with logistics (transit, food, siblings, language, adult present), schematic venue map, RSVP, check-in code, request a circle | Yes |
| Growth | Effort points by source, the "This month" chart, all 11 badges (verified vs self-reported), progress toward becoming a mentor. No leaderboard. | Yes |
| Profile · Safety · Family view | Goals that regenerate the roadmap; report/block rules and the immigration-data stance; the family view in Spanish with glossary and interpreter / ride / callback requests, shareable as a link or QR | Yes |
| Offline data | What is saved, the queue of writes waiting to send, demo scenarios (labelled), simulate no signal, reset | Yes |
Some controls are placeholders until the server exists; Known gaps lists them.
Onboarding asks grade, languages, English-learner status, 18 or older, up to three goals, and the aid-form question as a form choice ("Do you have a Social Security number you can use on aid forms?"). It never asks for status, birthday or a photo.
- Real: the roadmap engine (
src/domain/roadmap.ts,deadlines.ts), the message guard (guard.ts), matching score and constraints (matching.ts), effort and badge rules (effort.ts,badges.ts), the content pipeline with its build gates, every screen, offline storage and the write queue, Spanish and English. - Content: 22 steps (College 14, Language & family 7, one exploration step) with official sources and statewide dates (Cal Grant Mar 2, CSU Nov 30, UC Nov 30, decision day May 1). Every step is marked pending counselor review and the app says so on screen. District windows in
content/district_calendar.jsonare placeholders labelled "demo date". - Demo data: mentors, circles, the chat thread and the student "Ana M." are fabricated (
assets/data/demo/*), never uploaded, and marked by a demo note under the large title on most screens (see Known gaps). Valley High is fictional. - Not built yet: the server (Supabase behind Expo API routes), sign-in, the coordinator console, mentor mode, staff paging, reminders by push/SMS, the web family page, and the report and block actions. The plan for all of it is in
.omc/plans/nextstep-v1-production-plan.md.
Nowhere in the student's decision path. The guard is a word and pattern list (src/domain/guard.ts); matching is six weighted terms you can read on screen; deadlines are rules resolved in America/Los_Angeles. The plan allows one optional server route later (chat translation with the original kept, off by default).
Nowhere yet. Everything runs on the phone from assets/data/content.json and SQLite. Writes (step opened or finished, RSVP, correction, profile change) are queued in mutations:v1 and shown on the Offline data screen; the API that drains the queue is not built. Chat messages are not queued yet (see Known gaps). Code comments flag anything that waits on the API (usually "until the server exists" or "no server yet"); the plan's milestones (M0 to M5 in .omc/plans/nextstep-v1-production-plan.md) schedule each piece, and a few comments cite them ("M2", "plan M3").
Expo SDK 57 · expo-router · React Native 0.86 · TypeScript strict · zod · jest-expo. Local storage is expo-sqlite/kv-store (synchronous reads, so the first frame renders from disk). No map SDK, no analytics, no API keys on the phone.
npm install
npm run content:build # content/*.json → assets/data/content.json, with gates
npx expo start # i / a / wIn the app: Profile → Offline data & demo → Spring loads the demo student (Feb 7, 2027: Cal Grant in 23 days) with labelled demo data.
npm run typecheck # tsc --noEmit
npm run lint # expo lint (domain/server import boundaries are lint rules)
npm test # 136 tests: deadlines (DST, time zones), roadmap engine, guard corpus, matching constraints (1,000 random pairs), badges, content gates, i18n parity, token literals, screen smoke tests with zero networkcontent/steps.json is the source of truth (one row per step: owner, source URL, last verified, verify-by, verifier, English and Spanish text, deadline rule, prerequisites, grades). npm run content:build refuses a step missing any of those, a step older than 120 days, or English above a grade-6.5 reading level. content/REVIEW.md records who reviewed what; nothing is reviewed yet.
src/app screens (expo-router) ┐
src/ui design tokens and widgets │ React / React Native
src/store app state and derived hooks │
src/services demo scenarios, network watch ┘
src/data on-device storage (SQLite key-value)
src/i18n strings (en, es)
src/domain pure rules: roadmap, deadlines, matching, guard, badges (no React, no Expo)
content/*.json → scripts/build-content.ts → assets/data/content.json (bundled, read offline)
Project rules:
src/domainimports nothing from React, React Native, Expo or the rest of the app. A lint rule and__tests__/theme-literals.test.tsenforce it for React, Expo and the app layers (@/ui,@/store,@/services,@/data,@/server).- Screens never touch storage. They read state with
useAppStateand the hooks insrc/store/derived.ts, and change it throughactionsinsrc/store/appStore.ts. - Colours and type sizes are written only in
src/ui/theme.ts. A test fails on literals anywhere else. - Deadline dates and offsets live in
content/steps.jsonandcontent/district_calendar.json; the code holds only the rule anchors (the Aug 1 school year and the Sep 1 and Feb 1 season starts). Other dates in code are demo values (the picker labels "Spring · Feb 7, Cal Grant in 23 days" and "Deadline week · Feb 24", and the demo clock instants insrc/services/demo.ts) and fixed reference dates used only to name weekdays and format clock times (weekdayShortandtimeRangeinsrc/ui/circle-widgets.tsx,weekdayNameinsrc/app/(tabs)/index.tsx). None of them is a deadline.
- Startup.
src/app/_layout.tsxruns at module scope: it sets the locale from the device, callshydrate()to read every record from SQLite synchronously, and callsrestoreDemoScenario()to re-apply the demo clock. The first frame renders from disk and nothing waits on the network. - Reading. Screens call
useAppStateand the hooks insrc/store/derived.ts.useRoadmap()rerunsgenerate()fromsrc/domain/roadmap.tswith the profile, the content, the stored progress and the current time, so every screen sees the same list.useNextStep()picks the one step shown on home. - Writing. A screen calls an action such as
actions.finishStep(). The action writes to SQLite through a repository insrc/data/repos.ts, regenerates the roadmap, recomputes badges, updates state, and appends a record to the write queue (mutations:v1) for the future server. The Offline data screen lists that queue. - Demo mode.
src/services/demo.tsmoves the app clock to a fixed moment in 2027, shifts content verification dates by the days between the content build and that moment so the 120-day staleness rule keeps steps visible, and replaces local records with labelled demo data. Mentors and circles come fromassets/data/demo/only while a demo is on. - Offline.
src/services/network.tsstores online or offline in state andScreenshows the banner. No code path needs the network today.
| File | What it does |
|---|---|
types.ts |
Shared data shapes: content (steps, resources, glossary, badges), the student profile, roadmap rows, mentors, matches, messages, circles, cache metadata. Plain JSON, no logic. |
time.ts |
Calendar math in America/Los_Angeles using Intl: the demo clock offset, wall-clock to epoch conversion that survives DST, the Aug 1 school-year boundary, quiet hours (21:00 to 07:00) and "4 min ago" units. |
deadlines.ts |
Turns a step's deadline rule into the end of a due day: a fixed MM-DD, an offset from a grade and season, a district calendar window, or N days after the step was opened. Also daysLeft() and the 120-day overdue horizon. |
content.ts |
zod schemas for every content file, cross-record checks (unknown prerequisites, cycles, badge references, the exploration step), the 120-day staleness rule, the goal-to-track map, and the demo-only date shift. |
roadmap.ts |
The engine. Selects a student's steps (tracks, core steps, exploration step, onlyIf, grade, staleness), applies the first matching variant (for example FAFSA to the Dream Act form), resolves due dates, sets each status, orders the list and picks the one next step. Also coming-up, per-track progress, "why your roadmap differs" and the reschedule diff. |
matching.ts |
Mentor ranking: six weighted terms, hard constraints (capacity, training, 18 or older, blocks, staff-only students), fairness timers (7-day language wait, 10-day longest wait) and the guardian-notice gate announceable(). It ranks; a person confirms. The screens use terms, score, explain and WEIGHTS; rank, the hard constraints, fairness and announceable() run only in tests until the server exists. |
guard.ts |
Pattern classifier for chat text: contact information, meeting requests, money, crisis language, immigration-legal questions and homework requests. Redacts blocked spans and decides what report the guard itself opens (reportFor(), which only tests call so far). |
effort.ts |
Points per event kind, totals, private levels, points by source and by recent week. No points for streaks or time in the app. |
badges.ts |
Checks badge criteria against verified steps, circle attendance, peer help and accepted resources. Keeps the original earn date and marks a badge self-reported when any counted step was self-attested. |
format.ts |
Describes a due date as data (overdue, today, in N days, rolling) plus its tone, formats month and day, and turns a fit score into a word and a bar count. |
ids.ts |
Random ids for local records and family-link tokens. |
| File | What it does |
|---|---|
src/data/kv.ts |
Synchronous JSON key-value store on expo-sqlite/kv-store. Remembers whether the last write reached disk. |
src/data/kv.web.ts |
The same API on localStorage for web builds. |
src/data/repos.ts |
One repository per record type: profile, progress, effort, badges, settings, content, write queue, RSVPs, attendance, matches, messages, family link, cache metadata. Sanitizes what it reads. When a write fails it drops the downloaded content copy and every message list (today the only copy of sent messages), retries once, and records a storage notice. |
src/store/appStore.ts |
The single app state on useSyncExternalStore, the synchronous hydrate(), and actions. Most actions write to disk first, then update state. |
src/store/derived.ts |
Hooks that compute the roadmap, next step, coming-up, track progress and badge progress from state, expose demo mentors and circles, and tick once a minute so day counts stay current. |
src/services/demo.ts |
Demo scenarios (spring, deadline-week, verified): clock shift, content date shift, and labelled demo records for the student Ana M. |
src/services/network.ts |
Watches connectivity (expo-network on devices, browser events on web) and stores online or offline in state. |
src/i18n/index.ts |
t(), tn() for plurals, tFor() for an explicit locale, pick() and pickList() to read the Spanish field of a content record when it exists, and setLocale(). |
src/i18n/en.ts, es.ts |
String catalogs with identical keys. A test enforces parity. |
| File | What it does |
|---|---|
theme.ts |
Every design token: colours, track colours, map colours, the type scale, spacing and radii. |
icons.tsx |
Icon and ContentIcon: SF Symbols on iOS, Ionicons elsewhere, hidden from screen readers unless given a label. |
primitives.tsx |
Grouped-list building blocks: Group, Cell, SectionHeader, SectionFooter, Callout, Button (with haptics), Segmented, Chips, Toggle, Field, KeyValue, ProgressBar, TrackPill, Tag, InitialsAvatar, Bars, Empty. |
Screen.tsx |
Page scaffold: offline banner, back header, large title with the demo note and status line, scrolling body, pinned footer. Also goBackOr(). |
step-widgets.tsx |
The next-step hero, roadmap rows with status glyphs, the track progress list, and the due-date text helpers. |
mentor-widgets.tsx |
Mentor card (fit as a word and bars), can and cannot lists, the match explanation bars, and adapters that feed local data to the matching engine. |
chat-widgets.tsx |
Chat bubbles, system notices, and the composer that runs the guard while you type and refuses blocked text. |
circle-widgets.tsx |
Circle card and the schematic venue map (SVG on a 0 to 100 grid, no GPS). |
family-widgets.tsx |
Family view sections: upcoming dates in the family's language, the glossary, help requests and the match notice banner. |
| Route file | What it does |
|---|---|
_layout.tsx |
Boots the app (locale, hydration, demo clock, network watch) and declares the stack. Onboarding shows until the onboarding-finished flag is set. |
(tabs)/_layout.tsx |
The five tabs: Next, Roadmap, Mentor, Circles, Growth. Shows an unread dot on Mentor. |
(tabs)/index.tsx |
Next: the one next step, progress, coming up, mentor card, circle card, family view prompt. |
(tabs)/roadmap.tsx |
Roadmap: track filter, progress, rows grouped by status, why your roadmap differs, re-check. |
(tabs)/mentor.tsx |
Mentor: a row that opens the thread (with the last message) when matched, a notice-pending message, otherwise ranked mentors. |
(tabs)/circles.tsx |
Circles: filters, venue map, circle cards, request a circle. |
(tabs)/growth.tsx |
Growth: points by source, the "This month" chart, badge grid with details, progress toward becoming a mentor. |
step/[id].tsx |
Step detail: why it matters, documents, how-to checklist, source and resources, correction button, finish. |
step/[id]/done.tsx |
Choose how the step is verified (mentor, screenshot, coordinator or self) and finish it. |
verified/[id].tsx |
Result after finishing: how it was verified, what the school sees, badges earned or in progress, the next step. |
onboarding.tsx |
One question per screen. Builds the profile and the first roadmap. |
mentors/index.tsx |
Mentor discovery with filters and fit. |
mentor/[id].tsx |
Mentor profile: path, can and cannot lists, rules, request or join the waitlist. |
chat/[matchId].tsx |
The guarded chat thread. |
circle/[id].tsx |
Circle detail: logistics, RSVP, check-in code. |
circles/request.tsx |
Ask for a circle in your area. |
profile.tsx |
Goals and aid form (editing regenerates the roadmap), mentor and circle links, export, delete. |
safety.tsx |
Report and block, what happens after a report, rules for under-18 students, the immigration-data stance. |
family.tsx |
Family view preview, share link and QR code, language switch, regenerate or revoke the link. |
data.tsx |
Offline data: content version, queued writes, demo picker, simulated offline, reset. |
why/roadmap.tsx |
Plain-language explanation of the engine's rules. |
privacy.tsx, terms.tsx |
The data-minimisation table and draft plain-language terms. |
+not-found.tsx |
Fallback for unknown routes. |
| File | What it does |
|---|---|
scripts/build-content.ts |
Validates content (schema, cross-record rules, freshness, English reading grade) and writes assets/data/content.json with a content-hash version. |
__mocks__/expo-sqlite/kv-store.ts |
In-memory stand-in for the SQLite store in Jest. |
__tests__/domain/*.test.ts |
Deadlines (DST, time zones), roadmap engine, guard corpus, matching constraints, effort and badges. |
__tests__/content.test.ts |
The bundled content passes the build gates and gives the demo student one next step. |
__tests__/i18n.test.ts |
Key and placeholder parity between English and Spanish. |
__tests__/theme-literals.test.ts |
No colour or type literals outside theme.ts; no React or Expo imports in src/domain. |
__tests__/screens.test.tsx |
Smoke tests for 17 screens, offline and in the demo (onboarding on real data); six tests also assert zero fetch calls. |
The app runs without a server, so some controls only change what the screen shows. These are the gaps and known bugs to fix before real students use it.
- Crisis and routing. On the phone only blocking findings do anything: phone numbers, addresses, handles, meeting requests and money disable Send. Crisis language, immigration-legal questions and homework requests are classified, but nothing happens: no crisis resources, no routed note, no nudge, no alert, and the message is saved as written. No screen shows the bundled crisis resources, and
reportFor()runs only in tests. - Placeholders. Report and Block on the Safety screen do nothing yet; the flag icons on chat, mentor and circle screens only open that screen. Family help requests, "Ask for a circle" and "Remind me" update the screen but send or schedule nothing. The family notice "I don't want this" does nothing. "Tell my circle I finished" only opens the Circles tab. Family links and QR codes point at the placeholder
https://nextstep.exampleuntilEXPO_PUBLIC_API_URLis set, and nothing serves them; "Make a new link" and "Turn the link off" change only the phone's copy. - Guard patterns. The immigration pattern is case-sensitive, so "DACA", "Visa" or "Green card" with a capital letter are not classified at all. The crisis pattern misses the accented "abusa de mí", because
\btreats "í" as a non-word character. Addresses in Spanish word order, such as "Calle Mayor 123", are not caught. - Not stored or not queued. Chat messages are saved on the phone marked pending but are not added to the write queue. The guardian phone and email asked in onboarding are not stored. The screenshot path keeps no copy of the image; only "has evidence" is queued.
- Verification. Outside a demo the mentor path is disabled (there are no mentors), and the screenshot and coordinator paths leave the step waiting for confirmation; nothing can confirm it until the server exists. A step waiting for confirmation still blocks the steps after it and still shows Finish, so it can be submitted again. Inside a demo the mentor, screenshot and coordinator paths count as verified at once.
- Data loss. On the Offline data screen, tapping "Off (real data)" while already on real data resets the app and deletes the profile.
- Wrong or misleading text. "N mentors at your school did this" counts mentors from every school. Blocked roadmap rows read "After: 1" (a count) instead of the step names. The Mentor tab labels the top-ranked mentor "Strong fit" whatever the score. The Circles footer reuses mentor copy when you have no circle. The chat says "Logged for safety", the guard warning says a message "would be blocked and reviewed", and the Safety screen says a reported conversation "freezes immediately"; none of that logging, review or freezing exists yet. The family view says "1 days overdue" for one day. The roadmap explainer promises a nudge after 7 days, a circle invitation after 14 and a coordinator alert after 21; none is sent. The Safety screen's review times, logging and family-notice lines, the onboarding promise that a parent's contact is used for notices, the screenshot privacy lines and the glossary's "reviewed by a bilingual counselor" footer describe the planned service, not what the app does.
- Language. These always show English: the can and cannot headers and the path rows, kind tag and request button on a mentor profile; "steps", "rows" and "downloaded" on Offline data; the map's "Park"; the "CC transfer" filter; the word "class" on the home mentor card; the date in the demo note; the chart labels on Growth; the chat's VoiceOver labels; the queue entry names on Offline data (such as "step_done"); and the body text of the Privacy and Terms screens.
- Demo labelling. The demo note sits under large titles only, so step detail, the verified result, the mentor profile, circle details and the chat thread show demo data with no label. The
common.demoTagstring is never used. - Accessibility. A roadmap row's button reads only the step title in VoiceOver; its status, due date, track and tags are never read.
- Navigation. No screen links to Privacy or Terms; they open only by URL.
- Engine. The "still classified as an English learner" reason never appears, because English-learner steps use
onlyIfinstead of a variant. - Smaller issues.
- Circle check-in can be repeated after leaving the screen.
- The family-link token carries 80 random bits, not the 128 the plan asks for.
- When storage is full, the storage guard in
src/data/repos.tsdrops message lists, which today are the only copy of sent messages. - Nothing installs a
crypto.getRandomValuespolyfill (react-native-get-random-values is a dependency but never imported), so ids and tokens fall back toMath.randomwherever the runtime lacks it. - The mentor profile computes fit from open steps only, while the Mentor tab and Mentors screen also count stalled ones, so the fit word can differ.
- A blocked message of three characters or fewer, such as "$20", is cleared from the box without being sent and without a notice.
- The content schema ignores a misspelled variant condition key; a condition whose keys are all misspelled matches every student (an
onlyIflike that shows the step to everyone). - Resources and glossary entries are never age-checked, and the names in
content/tracks.jsonare not shown (screens use thetrack.*strings).