Inspiration

Every student I know has the same failure mode: they know the exam date weeks out and still start the night before. It isn't laziness — "study" isn't a task, it's a decision, and the decision is expensive. Most study apps hand you an empty calendar and wish you luck.

So the thesis was narrow: starting must feel easier than planning. If the app ever asks the student to design their week, it has already lost.

What it does

You enter one exam — date, subjects, roughly how much material, which days you're free. A deterministic scheduler works backward and produces dated tasks.

From then on the Today screen holds one commitment and one button. A timer runs while you study; you tap done.

The parts that make it survive a real week:

  • Miss a day and it replans. Remaining work redistributes across the days left — no timetable to rebuild, no reset to zero.
  • Streaks for sessions finished, not hours vaguely "spent studying."
  • Reminders at a time you pick, with quiet hours.
  • Readiness and progress you can actually see.

Two decisions I'd defend to a judge:

No AI in the plan. The schedule is a published, deterministic formula — same inputs, same plan, and the app can always explain why a task landed where it did. A student nine days out doesn't want a language model improvising their revision.

Days are local dates, not timestamps. Anything day-shaped is a YYYY-MM-DD string in the user's timezone. DST and travel can't silently eat someone's streak.

How I built it

Expo SDK 57 / React Native 0.86, TypeScript in strict mode. Supabase for auth, Postgres and RLS. RevenueCat for monetization. TanStack Query owns server state; Zustand holds only the onboarding draft and the live timer.

The rule the codebase is built around: screens contain no business logic. Any rule you can state without saying "React" lives as a pure function with a test. Plan generation, rescheduling, streaks, quiet hours and readiness all take now as a parameter — they never read the clock. That's what turns "exam is tomorrow" and "session crosses a DST boundary" into unit tests instead of manual QA. 95 test files back it.

Two details I'm glad I got right early:

  • The timer derives elapsed time from stored timestamps, never an accumulating interval — so it survives backgrounding, a phone call, and app termination.
  • Completion writes are idempotent — the client generates the row id, so a retry on a flaky connection can't double-count a session.

Every integration degrades to a no-op: missing Supabase, RevenueCat, PostHog, Sentry or EAS credentials produces a safe no-op plus one dev warning. The repo runs and its tests pass before any external account exists.

RevenueCat and the monetization model

Entitlement is never a boolean I stored. It always comes from CustomerInfo. There is no isPro flag and no local cache I trust over the SDK. Screens read useAccessLevel() and pass that level to one pure gate function — so every gating decision in the product is one testable function, not conditions scattered through views.

The trial is RevenueCat's, not mine. The 7-day trial is an App Store introductory offer on the annual product. There is no app-side trial clock — an earlier version ran one from a Postgres trigger and I deleted it. The paywall reads terms from the package's introPrice and only claims a free trial when the store reports one, so the copy can't promise an offer a user isn't eligible for. Prices come live from the offering, so they localize.

The webhook is an audit log, not a source of truth. RevenueCat posts to a Supabase Edge Function that records events into Postgres. The app never reads that table to decide access. If the webhook is down, entitlement still works.

The decision I'd most defend: a permanent free tier, not a countdown.

The core study loop is free forever — build a plan, run sessions, keep a streak, see history. There is deliberately no gate on starting a study session; a study app whose free tier can't run a study session isn't a free tier, it's a demo. Signing in, restoring purchases, support, export and account deletion are never paywalled either.

Pro sells capacity and control — what a student actually hits a wall on:

Free Pro — $4.99/mo or $29.99/yr
One active exam Unlimited concurrent exams
Preset session lengths Custom session length
Basic reminders Advanced scheduling
7- and 30-day progress Unbounded "All time" archive

Why I think this makes more money than a hard trial wall: the upgrade trigger is a real moment — finals week, when three exams land at once. A user who churned on day 8 of a countdown isn't there when that moment arrives. A user still keeping a streak on the free tier is. The paywall shows up at the second exam, not at an arbitrary date.

Challenges I ran into

Postgres grants versus RLS. Every table had correct RLS policies and every request still failed with 42501. Postgres checks the table GRANT before the policy, and Supabase's default privileges for the migration role don't include select/insert/update/delete. Policies without a grant is a silent, total failure — and it only surfaced against a real database with two real accounts.

A security definer trigger that defeated its own check. A trigger guarded columns using current_user. As security definer, current_user is the function owner — so the check passed for everyone. It had to be security invoker.

Auth tokens don't fit in the KeyStore. Android rejects values over ~2 KB and a Supabase session exceeds that, so tokens go through a chunking SecureStore adapter.

Notifications made me cut a feature. I'd planned a weekly recap notification. Its body is a count for a week that hasn't finished — a locally scheduled one would quote stale numbers, and computing it at delivery needs a server this design doesn't have. I deleted the feature rather than ship one that lies.

The paywall can't be seen on a simulator. A local build has a different bundle id, so StoreKit can't fetch production products and the offering resolves to nothing. Every paywall change went to a real device with a sandbox account.

Accomplishments that I'm proud of

The core rule held: the study loop is never paywalled at any access level, and a free user never loses access to their own data — both enforced in one pure module rather than by discipline. Business logic is pure enough that "exam is tomorrow" and "user changed timezone" are unit tests, not bug reports. And the app runs green before a single external account exists.

What I learned

The honest answer to "should this be a server feature?" is usually yes, and the right response to "I can't build the server" is to cut the feature — not ship a client-side approximation that quietly tells users something false.

On monetization: I assumed trial length was the lever. It isn't. The lever is where the wall sits. Moving it to a moment of genuine need instead of a date on a calendar made the free tier something I'm willing to defend rather than something I'm trying to make painful.

What's next for StudyAhead

Ship the accountability partner (currently a documented placeholder). Android — everything is written for it, the Play products aren't created. Turn on email confirmation, which sits behind one feature constant. And widen device coverage beyond one physical device and one tester.

Built With

Share this project:

Updates