A personalised study planner for Physics Wallah batches. It turns a student's subject, faculty and chapter choices into a day-by-day schedule, tracks watched lectures automatically, and adapts when they fall behind. Designed and built solo — now 200,000+ active users.
Students pick a batch, subjects, faculty per subject and optionally narrow chapters, then choose a 90 or 120 day horizon, a study style (rotation or focus), playback speed and a daily ceiling. The plan is stored as those selections, not as a materialised list.
A pure, side-effect-free scheduler derives today's list, backlog, pace, health and overflow from the plan on every read. Nothing per-day is stored, so a change to the plan is reflected instantly and the engine is fully unit-tested in isolation.
Watch stats are pulled from PW's video API on each visit and lectures the student already watched are ticked off automatically. The sync picks lectures round-robin across subjects so no subject is starved.
Fall behind and the planner offers a recovery window that spreads owed minutes over 7, 14 or 30 days. Plans can be extended twice, individual items deferred or pulled forward, and yesterday's misses acknowledged. Eight progress actions in total.
Signed-in PW students are adopted straight from the shared pw.live cookie, verified locally by signature. Everyone else goes through a Turnstile-gated OTP. Sessions are HMAC-signed cookies; PW tokens never reach the browser.
A printable plan export, plus a per-request CSP nonce with strict-dynamic, double-submit CSRF, nine security headers and an ESLint rule that fails the build on inline styles because they fail silently under the strict CSP in production.
A single Next.js 16 service, no workers, no cron. Routes handle gating and persistence; a pure domain layer under lib/planner does all scheduling maths; an integration layer wraps PW's APIs with budgets and retries. Course content is committed JSON pinned by a content version, MongoDB holds plans and members, and Redis holds PW tokens and rate-limit counters.
Redis: PW access and refresh tokens, a per-phone refresh lock, and fixed-window rate-limit counters.
Each plan stored its full item list, about 131 KB of a 133 KB document, written once and never changed. As sign-ups grew the working set outgrew the database cache on a shared cluster, reads went to disk, and plan lookups started timing out during launch traffic.
Three mechanisms in order. Derive items on read from the student's selections plus committed content, shrinking documents to about 2 KB. Never fetch the list at all by projecting it out of every query. Create the phone index imperatively at runtime because the declared index build had silently failed. Because completed progress refers to items by id, the rebuild verifies that every completed id exists and falls back to a full read if any is missing. A read-only verification script proved every plan was rebuildable before anything was deleted, and migration runs opportunistically on read so it converges as students arrive.
Today's target was remaining work divided by remaining days. Completing a lecture freed its share and the day refilled with the next one, so finishing work made the list longer. Moving a lecture off today pulled a different one in, so the day changed rather than shrank.
One invariant: today's budget is fixed at the start of the day and what you do only changes what is left of it. Three subtractions from the same budget: pinned minutes, minutes moved away from today specifically, and minutes completed today added back explicitly since completed items have already left the remaining pool. The fill step also knows what is already on the day so the never-hand-back-an-empty-day rule cannot award a bonus lecture the moment someone finishes.
PW rotates the refresh token on every use, and the access token pw.live holds in its own cookie is the same one this app spends when it refreshes. Drop the replacement and pw.live's cookie is dead, so the student is bounced back to OTP on the main site.
The refresh result makes the updated cookie non-optional on success and the rule is documented: if a route cannot set a cookie the browser will keep, it must not refresh. The sync endpoint, which runs on every page load, reads the existing token and skips when there is none so it cannot race the session route. Writes are ordered so the rotated refresh token is stored before the new access token is used.
X-Forwarded-Host is appended to per proxy hop. Production had two hops where staging had one, so comparing Origin against it never matched and blocking on that comparison took production login down while staging stayed green.
Collect every entry in the forwarded chain plus Host, demote the Origin comparison to a logged warning, and rely on double-submit CSRF: a script-readable cookie echoed in a header and compared in constant time, plus SameSite=Lax on the session cookie. A check that fails closed on infrastructure you do not control is worse than no check.
PW's stats endpoint pages at 20 ids and can be slow. A naïve sync of a 700-item plan would time out, and a front slice of 40 items was entirely Physics because items are stored subject by subject, leaving most of a student's watching permanently unsyncable.
Chunk at 20 with a hard cap on chunks, a 3 s per-chunk timeout and a 5 s total budget; use partial results and pick up the rest next visit. Select the 40 lectures to sync round-robin across subjects. Rate-limit the sync per phone in Redis before any database work so a throttled sync costs one Redis GET.