CAT aspirants do hundreds of practice questions but keep scoring the same marks. Not because they lack knowledge — because they repeat the same mistakes and never stop to fix them.
Every other prep app optimises for the feeling of progress (questions done, topics covered). CATalyst optimises for the one thing that actually improves scores: diagnosing and fixing your specific error patterns.
┌─────────────────────────────────────────┐
│ │
Solve a question │
│ │
▼ │
Get it wrong │
│ │
▼ │
Tag the error type (mandatory) │
├── calculation_error (knew it, fumbled it) │
├── concept_gap (didn't understand it) │
└── guess (had no idea) │
│ │
▼ │
Error Log — see the mark cost │
per error type, per subject │
│ │
▼ │
"Fix My Mistakes" button │
│ │
┌─────────┴─────────┐ │
▼ ▼ │
Phase 1 (RED) Phase 2 (BLUE) │
Re-attempt your Drill your │
logged errors weakest topic │
│ │ │
└─────────┬─────────┘ │
▼ │
Session complete screen │
(X mistakes fixed this session) │
│ │
└─────────────────────────────────────────┘
This loop is the product. Everything else is infrastructure.
Single-file SPA — no framework, no bundler, no build step. Open index.html in a browser.
Script load order is critical — each file depends on the previous:
config.js → db.js → auth.js → dashboard.js → practice.js
→ test.js → errorlog.js → onboarding.js → app.js
app.js is always last. It boots the app after all other modules are defined.
User action
│
▼
DB.* (all data ops go through here — never raw Supabase calls)
│
├── Supabase Postgres (source of truth)
└── localStorage cache (namespaced: cat_<key>_${userId})
│
└── Why namespaced? Multi-user devices.
cat_trial_abc123 ≠ cat_trial_xyz789
The most complex file. Key variables:
_isFixSession // true when launched from "Fix My Mistakes"
_fixPhase // 1 = re-attempt errors (red UI), 2 = drill topic (blue UI)
_fixedInSession // locked copy of Phase 1 correct count
// before Phase 2 resets _sessionCorrect
_sessionTimes // NOT reset between phases — cumulative for session screenFix session flow:
loadFixSession()
→ Phase 1 questions (red UI)
→ _showFixTransition() ← 2.5s auto-advance
→ _startPhase2() (blue UI)
→ _showFixSessionComplete()
Notifications with pending mistakes set url to /#fix. On click:
// app.js — _handleDeepLink()
if (window.location.hash === '#fix') {
setTimeout(() => Practice.loadFixSession(), 400);
}Sends the user straight into Fix Mode — no menu, no friction.
Why vanilla JS instead of React?
Speed of iteration. No reconciliation, no virtual DOM, no build step. App.navigate(page) hides all .page divs and shows #page-<name>. That's the entire router. When a bug hits at 11pm, I'm reading one file, not tracing through 14 components.
Why sets!left in the Supabase query?
PostgREST defaults to INNER JOIN. Without !left, any question where set_id IS NULL is silently dropped. Spent 3 hours debugging missing questions before finding this. Now permanently documented.
Why KaTeX instead of MathJax?
KaTeX renders synchronously. MathJax is async and caused questions to flash unstyled before math rendered. Never set question content as raw innerHTML — always route through renderMath(el, rawText, isRC).
Why localStorage cache alongside Supabase? Perceived performance. The dashboard loads instantly from cache while Supabase fetches in the background. For a PWA on 3G, this matters.
Why separate dev/prod Supabase instances? Analytics pollution. Every test session was showing up in real user analytics. Separate databases mean localhost never touches real data.
Why iOS PWA needs Supabase for tour state?
Safari and the installed PWA have separate localStorage on iOS. The onboarding tour completion flag is written to both localStorage AND Supabase events table — so it doesn't restart when a user switches from Safari to the PWA.
| Layer | Tech | Why |
|---|---|---|
| Frontend | Vanilla JS SPA | No build step, fast iteration |
| Auth | Supabase Auth | Email + Google OAuth, session management |
| Database | Supabase Postgres | RLS policies, realtime capable |
| Cache | localStorage (namespaced) | Instant loads, multi-user safe |
| Math | KaTeX | Synchronous render, no flash |
| Push | Web Push + Vercel Function | Daily "fix your mistakes" nudge |
| PWA | Service Worker (cache-first JS) | Offline reading, installable |
| Cron | cron-job.org → Vercel Function | 4×/day push scheduling |
| Hosting | Vercel | Zero config, instant deploys |
questions — content, type (mcq/tita), subject, topic, set_id
sets — passage/instruction for set questions
attempt_logs — every attempt: user_id, question_id, is_correct, time_taken
error_logs — wrong answers: user_id, question_id, error_type, is_fixed
events — analytics: signup, fix_mode_started, tour_completed, etc.
push_subscriptions — endpoint, p256dh, auth per usergit clone https://github.com/PoisonOps/catalyst-app.git
cd catalyst-app
npx serve .
# → localhost:3000 auto-uses dev Supabase (config.js detects hostname)
# No .env needed — dev credentials in js/config.jsLive payments via Razorpay — UPI, cards, net banking.
Two plans:
| Plan | Price | Notes |
|---|---|---|
| One-time (Best Value) | ₹489 | Access till CAT 2026 — ₹81/month · ₹21/week |
| Monthly | ₹99/month | Cancel anytime |
Both are founder's pricing for the first 20 users.
Razorpay integration details:
initiatePayment(source)— single function handles both the upgrade modal and the paywall, deduplicates double-clicks with a_paymentInFlightguardmodal.ondismisscallback resets the CTA button immediately when the user closes Razorpay without paying (prevents the "Opening payment…" stuck state)prefill: { email }pre-fills user's email fromAuth.currentUser.email- Brand logo:
icon-512-razorpay.png(512×512 PNG, upscaled viasipsfrom the original icon)
The payment UI was rebuilt from scratch with conversion-focused design:
- Hero: Big red mistake count (
#ef4444, 900-weight,cdNumGlowpulse) — user sees their own error count as the paywall hook - Slot machine: 10 rotating comparisons (Netflix appears 4× most frequently) — shows ₹489 is less than everyday purchases, rotates every 2600ms using Web Animations API
- Plan cards: One-time (₹489) pre-selected;
:has()selector dims the monthly card when one-time is selected - Progress bar:
@keyframes upv3BarGrow+ CSS--bar-pctvariable — animates fresh on every modal open - Urgency bar: Live countdown to CAT 2026, slots remaining
- Trust row: Razorpay lock icon + 7-day refund link →
/refund-policy.html
/refund-policy.html — standalone dark-themed page, linked from the payment modal.
Visible promise: 7-day refund guarantee (prominent blue highlight at top).
Actual eligibility bar (all conditions required):
- Request within 7 calendar days of purchase
- Active usage on at least 5 separate calendar days + 25 questions attempted — multiple sessions on the same day don't qualify
- Registered email to verify purchase
- One refund per user
- Monthly plan: only the most recent charge is refundable
794 questions + 145 sets across Quant, LRDI, and VARC.
Migrated from dev to prod via migrate-to-prod.js (safe to re-run — UPSERT):
DEV_SERVICE_KEY=xxx PROD_SERVICE_KEY=yyy node migrate-to-prod.jsI should have shipped with 50 questions, not 794. The Fix Mode loop was right from day one. I spent too long building content when I should have been validating the loop with real users. The push notification system was built before I had 10 users. Classic premature scaling. The lesson: ship the core loop first, everything else second.
Built by Sahil Solankey · CAT 2026 aspirant · solo founder
WhatsApp · Email