cat projects/tracker-360.md
    Live · pw.live
    Next.js 16
    React 19
    MongoDB
    Redis
    Docker · Kubernetes

    Tracker 360

    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.

    2025 – present · Physics Wallah · built solo Solo build — design, backend, frontend, deploy
    200K+
    Active users
    300K+
    Visits
    ~9,000 hrs
    Content planned per batch
    3,200 lectures · 2,137 DPPs
    413
    Chapters indexed
    27 faculties · 5 subjects
    2 KB
    Plan document
    Down from 133 KB
    126
    Scheduler test assertions
    All passing

    Screens

    tracker360.pw.live — pick your batch
    tracker360.pw.live — pick your batch

    What I built

    Plan wizard

    5 subjects · 27 faculties

    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.

    Daily schedule engine

    915 lines · pure

    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.

    Automatic progress sync

    Zero manual ticking

    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.

    Recovery, extensions & deferrals

    8 progress actions

    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.

    pw.live SSO + OTP fallback

    Fails closed without secret

    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.

    PDF export & security hardening

    Strict CSP

    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.

    How it's built

    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.

    Frontend
    • Next.js 16 App Router, React 19, Tailwind v4
    • Four-state root: checking, signed-out, no-batch, has-plan
    • Wizard and Today views drive the whole product
    • GA4 injected with the per-request CSP nonce
    API & security
    • 15 endpoints: 6 auth, 7 planner, 1 health, 1 export
    • proxy.ts runs before every request: CSP nonce, CSRF double-submit, security headers
    • Rate limits per IP and per phone; X-Forwarded-For read right-to-left so a client cannot spoof past them
    • Dependency-free /api/health so a Redis blip cannot restart a healthy pod
    Data (MongoDB + Redis)
    • Two collections: plans and members, joined by phone
    • Unique partial index: one active plan per student, archived plans never collide
    • Plan items derived on read from selections + committed content, never stored
    • contentVersion pins each plan to a content snapshot so a content update cannot alter a live syllabus

    Redis: PW access and refresh tokens, a per-phone refresh lock, and fixed-window rate-limit counters.

    Integration & infra
    • PW video-stats API called in chunks of 20 with a 5 s total budget; partial results are used
    • One retry on ambiguous PW statuses only; 401 is never retried
    • Three-stage Docker build on node:24-slim, standalone output, non-root user
    • Jenkins branch-to-environment pipeline; Elastic APM and ECS-format pino logs
    cat docs/request-flow.txt
    1. 1.Page load → POST /api/planner/sync (rate-limited in Redis before touching Mongo) + GET /api/planner/plan
    2. 2.plan → findOne({phone, status: active}, {items: 0}) with a 15 s maxTimeMS, projecting the item list out
    3. 3.hydratePlanItems rebuilds items from selections + committed JSON and verifies every completed id exists
    4. 4.deriveSchedule computes today, backlog, pace, health and overflow; derivePlanStats computes streaks and finish date
    5. 5.Student ticks an item → POST /api/planner/progress with CSRF header → append {itemId, completedAt}
    6. 6.plan.save() writes ~2 KB because items were unmarked as modified; today's list shrinks instead of refilling

    Hardest problems

    A 133 KB document per student, and a fix that could not lose progress

    Problem

    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.

    How I solved it

    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.

    lib/planner/materialise.ts · models/Plan.ts

    Today's list kept refilling when you finished something

    Problem

    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.

    How I solved it

    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.

    lib/planner/schedule.ts

    Refreshing our token silently killed the pw.live session

    Problem

    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.

    How I solved it

    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.

    lib/pw/ensureToken.ts · app/api/planner/sync

    A CSRF origin check that failed closed on infrastructure we do not control

    Problem

    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.

    How I solved it

    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.

    proxy.ts

    Syncing watch progress against a paginated, slow upstream

    Problem

    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.

    How I solved it

    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.

    lib/pw/videoStats.ts · app/api/planner/sync

    What I'd do differently

    • $Put the load-bearing invariants in types, not comments: a branded HydratedPlan type and a content hash computed at build time instead of a manually bumped version constant.
    • $Add tests for the integration layer, where every production incident actually happened; the pure scheduler is already well covered.
    • $Memoise the per-faculty item mapping in a process-level Map, since the content is immutable and version-pinned.
    • $Extract a planner service layer so the 765-line plan route can be tested without HTTP.
    • $Key plans on the PW user id rather than phone so a number change does not orphan a plan.

    Tech stack

    Next.js 16
    React 19
    TypeScript
    MongoDB
    Mongoose
    Redis
    Node.js
    Tailwind v4
    Docker
    Kubernetes
    Jenkins
    Elastic APM
    Cloudflare Turnstile
    $ cd ../portfolio