Skip to main content

Developer overview

ImtehanHub is a React 19 SPA that runs identically as a web app and as a Capacitor-wrapped Android app. The codebase optimises for a single developer-hour: every layer has one canonical pattern, every dependency earns its place, every feature lands behind a free-tier-only constraint.

TL;DR — TypeScript strict + Vite + Tailwind v4 + Radix UI + Zustand + TanStack Query + RHF + Zod + Firebase (free tier) + Capacitor. No paid services anywhere in the runtime path. The whole thing builds with yarn build.

The stack

LayerTechnologyWhy
LanguageTypeScript 5.9 (strict, noUncheckedIndexedAccess)Bug-prevention compounds; the strict modes catch real issues in months of growth
BuildVite 8Fastest cold start, best HMR, Rollup output
StylingTailwind v4 + Radix ThemesUtilities for layout, Radix for accessible primitives
UI primitivesRadix UIHeadless, accessible, theme-friendly — no native HTML for buttons / dialogs
RoutingReact Router 7Familiar, fast, file-list-driven (not file-system magic)
Client stateZustand 5 + persist + storage-adapterBoring and reliable; no boilerplate
Server stateTanStack Query 5Caching, stale-while-revalidate, optimistic updates
FormsReact Hook Form + Zod + @hookform/resolversOne pattern across every form in the app
DataFirebase Firestore (free tier)Real-time, security rules, no servers to run
AuthFirebase Auth (Google sign-in only)One auth path, zero password recovery flows
FilesGoogle Drive (user-owned, drive.file scope)We never host user images
MobileCapacitor 8Web app → native Android, same codebase
Rich textTipTap 3For admin authoring (blog, knowledge base)
NotificationsOneSignalFree tier, web + native
AnalyticsFirebase Analytics + Amplitude + Microsoft Clarity (no PII)Three lenses, fail-open if env keys missing
ErrorsSentryFirst-party SDK, PII-scrubbed
AdsAdMob (mobile) + AdSense (web)Free tier only — no banners on legal pages

Anything paid (Firebase Functions, custom backends, paid APIs, AI compute) is explicitly out of scope. See the zero-cost constraint below.

Repository layout

imtehanhub/
├── src/
│ ├── config/ — env, constants, routes, firebase
│ ├── types/ — TS types per domain (admin, billing, content, ...)
│ ├── repositories/ — BaseRepository<T> + per-collection Firestore wrappers
│ ├── services/ — business logic (auth, billing, content, referrals, ...)
│ ├── stores/ — Zustand stores (auth, theme, test, toast, ui, ai)
│ ├── hooks/ — TanStack Query + custom hooks
│ ├── lib/ — singletons (analytics, logger, storage, errors, seo, ...)
│ ├── components/ — Radix-based UI, layout, decorative SVG
│ ├── features/community/ — community module slice (own components/services/types)
│ └── pages/ — page components (public/, app/, admin/, community/, error/)
├── pipeline/ — free scrapers, transforms, validators, seeders
├── public/ — robots, llms.txt, ai.txt, manifest, SVG assets
├── postbuild-seo.mjs — generates per-route static HTMLs (660+) after Vite build
├── android/ — Capacitor Android shell (committed; no node_modules)
├── docs/ — module specs, audits, tracking JSONs
├── service-account/ — Firebase Admin key (in .gitignore for safety; private repo)
└── package.json

Each important folder has its own CLAUDE.md and AGENTS.md carrying the rules that apply inside it. The root files keep the global rules.

The layer contract

Reads and writes flow through fixed layers, in fixed order. Skipping a layer is a smell.

UI components / pages

TanStack Query hooks (src/hooks/use-*.ts)

Services (src/services/*-service.ts)

Repositories (src/repositories/*-repository.ts, extending BaseRepository<T>)

Firestore SDK (firebase/firestore)

State that doesn't come from the server (theme, UI panels, in-flight wizards) lives in Zustand stores, persisted via the storage adapter.

The layers reference walks through one full flow (a user submitting a test) across every layer.

Imports — always absolute

// ✅ Always
import { contentService } from '@/services/content-service';
import type { Class } from '@/types/content';

// ❌ Never
import { contentService } from '../../services/content-service';

The @ alias maps to src/ via Vite + tsconfig.json's paths. Relative imports are reserved for sibling files in the same directory only.

The zero-cost constraint

Every dependency, every API, every service must fit a free-tier budget:

  • Firebase: free tier only (Firestore 50K reads/day, Auth, Hosting). No Cloud Functions, no Firebase Storage.
  • AI/LLM: BYOK only (user provides their own OpenAI / Claude key). No bundled API keys.
  • Files: Google Drive (user's), or FilesHub for app-owned assets (visibility: 'public').
  • Backends: when truly needed, free Cloudflare Workers with project-prefixed secrets.
  • Email: none — we do not send transactional or marketing email (everything lives in-app or in push).
  • Banned npm packages: @capacitor-firebase/crashlytics, @capacitor-firebase/performance (Gradle plugin pain; we use Sentry instead).

This constraint is the whole product strategy — it's how a single developer ships and sustains a free Pakistani student tool. The repo's CLAUDE.md files cite specific incidents that produced each ban.

Logging — never console.* directly

Every log goes through src/utils/logger.ts. Direct console.log/info/debug/trace/warn/error/... is ESLint-banned (no-console: 'error'). The logger:

  • Mirrors the console.* surface 1-to-1.
  • Defaults to level warn in both dev and prod.
  • Reads localStorage['imtehanhub:logLevel'] and VITE_LOG_LEVEL for overrides.
  • Exposes window.__setLogLevel(level) / __getLogLevel() for devtools.
  • Patches global console.log/info/debug/trace to no-ops at boot so third-party stragglers stay quiet.

Why: one switch silences the dev console for an exam-day demo without code edits. The full rule + verification grep lives in the root CLAUDE.md.

URL state preservation

Every navigable UI state belongs in the URL:

StateMechanismExample
Modal / dialogsearch param?compose=1, ?edit=JOB_ID
Active tabsearch param?tab=pending
Multi-step formsearch param?step=2
Filter / sort / pagesearch param?status=failed&page=3
Detail viewsearch param?view=USER_ID
Search querysearch param?q=term

Helper hooks live at src/lib/hooks/useUrlState.ts: useUrlBooleanState, useUrlStringState, useUrlNullableString, useUrlNumberState. Raw useState for refreshable UI is a code-review reject.

Build pipeline

yarn dev # Vite dev server on port 5942 — USER starts this, not the agent
yarn typecheck # tsc --noEmit, must be clean
yarn lint # ESLint (no-console, no-unused-vars, exhaustive-deps), must be clean
yarn build # vite build + postbuild-seo.mjs (static HTMLs)
yarn build:dynamic # vite build + postbuild-seo.mjs --dynamic (pulls Firestore for per-entity HTML)
yarn preview # Preview built app on port 5943

A clean yarn build produces dist/ with 660+ static HTMLs — one per public route, per class page, per subject, per chapter, plus blog and knowledge-base entries when seeded. See postbuild SEO for the deep dive.

Where to start reading

  1. Layer contracts — types → repos → services → stores → hooks → components, walked through one flow.
  2. Routing — single src/config/routes.ts table-of-routes, no React-Router magic strings.
  3. State management — Zustand + persist + Capacitor-aware storage.
  4. Forms — RHF + Zod + project-wide field components.
  5. Data pipeline — how free scrapers + transforms + seeders produce official content.
  6. Postbuild SEO — why 660+ HTMLs, what JSON-LD ships, how dynamic entity-pages are generated.

Contributing

ImtehanHub's app repo is private. This documentation repo (aoneahsan/imtehanhub-docs) is public — typo fixes, clarifications, and new pages are welcome via PR. For product contributions, the community module is the supported path: submit MCQs, short/long questions, and chapter explanations directly through the app.

The dev team is one person: Ahsan Mahmood (aoneahsan@gmail.com, linkedin.com/in/aoneahsan, aoneahsan.com). All issues, ideas, and architectural questions go to that inbox.

Next