Practice Hub: two faces + the start-practice wizard¶
Status: shipped (web), partial (mobile) LNO: L. Defines the Practice Hub's information architecture: what a learner sees before an exam target exists (Face A) versus after (Face B), and the wizard that lets anyone practise without picking an exam at all. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C free, B2C Pro. B2B unaffected. Primary routes:
/practice(ROUTES.practiceHub),/practice/start(ROUTES.practiceStart,apps/web/src/constants/routes.ts:148) Source of the ask: PRs #1305 and #1277, 2026-07-10. Design and product decision, not a user interview. See the honest flag in Q1 below. Related: Practice Hub Engine, Practice Hub Freemium Gate, Practice Player, B2C Mobile App
TL;DR¶
The Practice Hub renders one of two faces, decided by a single line: does the plan carry a resolvable exam family?
- Face A (no exam target): a heading, one primary CTA into the start-practice wizard, and two exam tiles (JEE, NEET) that set the target in one tap.
- Face B (exam target set): the ranked priority map for that exam, a habit strip, and subject tabs.
The wizard at /practice/start is a 3-step React Hook Form + Zod flow (subject → chapters → aim). It is deliberately exam-agnostic: it reads a catalog of every subject and chapter that has bank questions across all exam families, so a learner who has never picked JEE or NEET can still start practising. When the chosen subject has no question bank, the wizard does not dead-end. It hands off to the AI tutor with the learner's intent pre-composed into a topic string.
Mobile ports the two-face model faithfully but has no wizard: both of its "start practice" affordances fall back to the AI tutor.
Why this exists¶
Q1. Who asked for it?¶
Honest flag: nobody asked. This was a design and product decision by @bhanu, landed as PRs #1305 and #1277 on 2026-07-10. It is not traceable to a user interview, a support ticket, or a data signal in anything committed to this repo. Treat the reasoning below as a hypothesis with a plausible mechanism, not as validated pain.
Q2. What user pain does it solve?¶
The pre-two-face hub had one shape for everyone, and that shape assumed an exam target. A learner whose onboarding did not resolve to JEE or NEET (a class-9 student, a PCMB student the inference guessed wrong, anyone who skipped the subject step) landed on a surface built to rank JEE chapters and got nothing rankable. The engine correctly returned an empty state, and the UI correctly rendered it, and the learner correctly left.
The second pain is narrower and sharper: the hub could only ever offer practice for the exam it had decided you were taking. A NEET aspirant who wanted twenty minutes on Organic Chemistry, or a class-10 student who just wanted to drill Optics, had no path. The priority map is the right surface for exam grinding and the wrong surface for "I want to practise this one thing right now."
Q3. What is the cost of NOT doing it?¶
Every learner without a resolved exam family, which is every non-exam-prep B2C signup, sees an empty Practice tab. Practice is one of the four things the product does. An empty tab there is not a neutral outcome; it teaches the learner that the tab is broken and they stop opening it. The cost is the entire non-exam segment never touching a paid surface.
Q4. Is the pain validated or a guess?¶
A guess. The mechanism is sound and the empty-state problem is real and code-visible (four distinct EmptyPlanReason codes exist precisely because the engine keeps hitting it), but no user told us this. The wizard's specific shape (three steps, four aim presets) is an unvalidated design bet. The aim presets in particular are a guess: nothing in the repo measures whether learners pick them meaningfully or just take the default.
Q5. How will we know it worked, from the learner's point of view?¶
The four practice_hub_* events exist to answer exactly this (see Analytics below):
- Does
practice_hub_wizard_startedconvert topractice_hub_wizard_completed, or do learners abandon at step 2? - On
practice_hub_wizard_completed, what is thepathsplit? A highchatshare means the bank is missing subjects learners actually want, which is a content problem, not a UI one. - Do learners who arrive via Face A ever come back to Face B, meaning the exam tiles are doing their job?
Sean Ellis counterfactual: if we removed the wizard a week after shipping, would anyone complain with specifics? For exam-prep learners, honestly no. They live on Face B and the wizard is a detour. For everyone else, they would not complain either, because they would simply go back to seeing an empty tab and quietly stop opening it. That asymmetry is the tell: this feature's value is entirely in the segment that does not yet have a voice in our feedback channels. That is a reason to instrument it hard, not a reason to be confident.
The two faces¶
The split is one condition in apps/web/src/pages/b2c/PracticeHub/index.tsx:108:
if (!plan.examFamily || !isExamTarget(plan.examFamily)) {
return <FaceAEmpty />
}
return <FaceB examFamily={plan.examFamily} plan={plan} />
examFamily comes straight from the engine's TStudyPlan. It is "" for every empty-state plan, so all four EmptyPlanReason codes (no_preferences, needs_exam_choice, no_exam_target, corpus_pending) land on Face A. The page renders a skeleton while loading and an InlineAlert + retry button on error, both before the face split.
Face A: no exam target¶
components/FaceAEmpty.tsx. Three blocks, top to bottom:
- A flag icon, heading, and body copy.
- Primary CTA,
practiceHub.faceA.startCta, navigating toROUTES.practiceStart. This is the important one: it is the escape hatch that does not require committing to an exam. - An "or" divider, then two exam tiles (JEE, NEET) from
EXAM_TARGET_OPTIONS. Tapping one firespractice_hub_exam_target_selectedand callsuseSetExamTarget(), whichPATCHes/practice-hub/exam-targetand gets the rebuilt plan back. The learner flips to Face B without a page navigation.
The tiles are toned per family: JEE on primary-*, NEET on secondary-* (EXAM_TILE_TONE, FaceAEmpty.tsx:12-15).
Face B: exam target set¶
FaceB in index.tsx:53, composed of four pieces:
| Component | What it shows |
|---|---|
PracticeHubHeader |
title + a badge naming the exam family |
HabitStrip |
streak, today's focus chapter, active phase ordinal |
SubjectTabs |
the exam family's subjects |
PriorityMapGrid |
the ranked chapter tiles for the active subject |
Plus a PaywallModal, opened when a free learner taps a locked tile.
Subject tabs¶
EXAM_FAMILY_SUBJECT_KEYS (constants.ts:102) is a const map, not a derivation from the plan payload:
The tabs are therefore fixed per exam family and always render all three, even when the bank has no chapters for one of them. That is deliberate: BankWeightageUnitProvider returns the full family map regardless of the learner's own onboarding subjects, and a subject with no bank rows renders an honest empty tile rather than vanishing from the tab bar. A learner never has to wonder whether a subject is missing or merely empty.
normalizeSubjectKey absorbs backend spelling variance (Mathematics / Math / Maths all map to mathematics), so tab matching never depends on the bank's exact casing.
The priority map¶
components/PriorityMapGrid.tsx. A 3-up grid at sm and above, one column below.
- Sort:
yieldPctdescending, computed client-side (PriorityMapGrid.tsx:116). Note this is not the engine'spriorityorder. The engine ranks byimportance × gapand the grid re-sorts by raw yield, so the visual rank-1 tile is the highest-yield chapter, not the highest-priority one. This matters because the free-tier lock keys off the grid's index, and so does the backend gate (below). - Weightage badge:
resolveWeightageLevel(yieldPct)buckets into high (≥ 10%), med (≥ 5%), low (constants.ts:110-118). - Gap ring:
GapRingrenders one of four states fromstatus+accuracyPct:not_started(hollow),mastered(≥ 65% or engine-marked),weak(< 20%),in_progress. Anot_startedchapter with a stray0accuracy still reads as not-started, not weak (resolveGapRingState,constants.ts:148). - Free-tier lock:
locked = isLocked && index + 1 > FIRST_UNLOCKED_RANKwhereFIRST_UNLOCKED_RANK = 1. Rank 1 is open to free learners; every other tile shows a padlock and opens the paywall instead of navigating. This mirrors the backend gate exactly (PracticeSessionService._enforce_free_tier_gate), and the backend derives its own rank-1 by re-applying the same yield-desc sort (StudyPlanService.top_chapter_id), so the two can never drift. - Empty subject:
HonestEmptyTile, which says this subject has no past-paper data and offers a CTA into the wizard.
Tapping an unlocked tile fires practice_hub_priority_tile_clicked and navigates to the practice session for that (subject, chapter).
The habit strip¶
components/HabitStrip.tsx. One compact, horizontally scrollable Card with up to three items: streak (fire icon), today's focus chapter (flag icon), and the active phase as an ordinal (Phase 2), not a name. It replaced a larger TodayCard + PhaseBar pair. The phase ordinal comes from PHASE_ORDINAL (diagnose: 1, master: 2, simulate: 3) and the item is omitted entirely when no phase is active.
The strip renders two numbers the backend does not compute
streakDays is hardcoded 0 in the assembler (apps/backend/src/services/plan_engine/assembler.py:232), and today.dueRevisions is hardcoded 0 (assembler.py:224). The streak item therefore always reads zero for every learner. This is a known gap, parked at M5 in the engine roadmap, and it is the single most visible piece of fake-looking UI on the surface.
The start-practice wizard¶
Route /practice/start (ROUTES.practiceStart, apps/web/src/constants/routes.ts:148). Source: apps/web/src/pages/b2c/PracticeStartWizard/.
One useForm at the orchestrator with zodResolver(wizardFormSchema), shared to the steps via FormProvider. Step position is useState<TStep>, not a form field. mode: 'onSubmit' and reValidateMode: 'onSubmit' so errors never fire on blur.
The form is five fields (schema.ts:10-16): subjectKey, chapterIds[], topic, aim, aimNote.
Step 1: subject¶
components/steps/SubjectStep.tsx. Four tiles from SUBJECT_DEFS: physics, chemistry, mathematics, biology. Note the wizard offers all four regardless of exam family, which is the whole point: it is exam-agnostic. Continue is gated by trigger(SUBJECT_STEP_FIELDS), the only Zod-gated transition in the wizard.
Step 2: chapters, or a topic¶
components/steps/ChaptersStep.tsx. This step forks on hasBank, resolved from the catalog for the subject the learner just picked:
- Bank exists: multi-select chips of that subject's chapters, merged across exam families and sorted by
pyqCountdescending. WriteschapterIds. - No bank: a single free-text input, "what do you want to practise?". Writes
topic.
Continue gates imperatively, not via Zod: canContinue = hasBank ? chapterIds.length > 0 : topic.trim().length > 0 (ChaptersStep.tsx:29). The schema carries a comment explaining the choice (schema.ts:4-9): requiredness depends on hasBank, which is catalog data keyed on a field the form itself owns, so a static schema cannot express it and a schema factory would have to run before the watch it depends on exists. One conditional rule, gated in one place, rather than a factory.
Step 3: aim¶
components/steps/AimStep.tsx. Four preset chips from AIM_PRESETS (constants.ts:12-17): build_fundamentals (the default), clear_doubt, speed_practice, full_test. Plus an optional free-text aimNote. This step always has a valid value, so its Continue is the final submit.
Submit: the routing decision¶
resolveWizardSubmit (helpers.ts:119-148) returns a discriminated union, { kind: 'session' | 'chat', href }:
hasBank AND canonicalSubject AND a first chapter id
→ kind: 'session' → the practice player for (subject, first chapter)
otherwise
→ kind: 'chat' → the AI tutor
The canonicalSubject guard matters: the session href must carry the backend-canonical subject string from the catalog (plan.subjects[].subject), never a translated display label, because that string is what the session endpoint matches against. If it is missing, the wizard falls back to chat rather than sending a malformed href to the player.
Only the first selected chapter opens a session. Multi-select in step 2 is currently wider than what the player consumes.
The no-bank handoff¶
This is the design's escape valve, and the reason the wizard can be offered to a learner with no exam target at all.
When the learner's subject has no question bank, buildPracticeChatHref (helpers.ts:75-78) composes:
The chapter param is not a chapter. It is composeChatTopic (helpers.ts:88-101): an i18n-templated sentence built from the subject, the free-text topic, and the chosen aim, with the optional aimNote appended. So a learner who picked Biology (no JEE bank), typed "photosynthesis", and chose "clear a doubt" lands in the AI tutor with their intent already stated, rather than at a blank chat box or a dead end.
The same handoff backs HonestEmptyTile on Face B, and on mobile it is the only path (below).
The exam-agnostic bank catalog¶
GET /api/v1/practice-hub/catalog (apps/backend/src/api/v1/practice_hub.py:71), served by StudyPlanService.catalog() (service.py:146).
It deliberately does not go through resolve_goal or BankWeightageUnitProvider, so it never depends on the caller's exam_target_override. It live-aggregates the whole bank (get_chapter_weightage(exam_family=None)), keeps rows with pyq_count > 0, and groups into one entry per (exam_family, subject) pair, each carrying hasBank: true and its chapters sorted by pyq_count descending. When the bank is unreachable it returns [] rather than erroring.
That shape is what makes the wizard work for a learner with no exam target. On the frontend, resolveChaptersForSubject (helpers.ts:49) merges a subject across exam families (Physics appears under both JEE and NEET), dedupes chapters by id, and re-sorts by pyqCount. resolveHasBank and resolveCanonicalSubject do the corresponding lookups.
The catalog is also the honest answer to "which subjects does the bank actually cover?". A subject with no bank rows simply is not in the response, and the wizard routes that learner to chat. No hardcoded coverage claim exists anywhere in the frontend.
Analytics¶
Four events, defined in packages/analytics/src/events.ts:19-22.
| Event | Fired at | Properties |
|---|---|---|
practice_hub_exam_target_selected |
Face A exam tile tap (FaceAEmpty.tsx:58) |
exam_target |
practice_hub_wizard_started |
wizard mount, useEffect (PracticeStartWizard/index.tsx:50) |
none |
practice_hub_wizard_completed |
wizard submit (PracticeStartWizard/index.tsx:84) |
aim, path (session or chat), subject |
practice_hub_priority_tile_clicked |
unlocked tile tap (PriorityMapGrid.tsx:120) |
subject, chapter_id, yield_pct |
path on the completion event is the load-bearing one. It is the only place the product reports how often a learner's chosen subject had no bank behind it.
Gaps worth knowing: there is no event for abandoning the wizard, no event for hitting the paywall from a locked tile, and no event for the HonestEmptyTile CTA. Step-level drop-off in a 3-step flow is therefore not measurable today; only start and completion are.
Mobile parity¶
apps/mobile-b2c/components/practice-hub/, with the screen at PracticeHubScreen.tsx mounted from app/(app)/(tabs)/practice.tsx.
What matches¶
The two-face split is a faithful port. PracticeHubScreen runs the same isExamTarget(plan.examFamily) condition, the same FaceA / FaceB composition, and the same four Face B pieces. The domain constants are mirrored one-for-one in components/practice-hub/constants.ts: EXAM_FAMILY_SUBJECT_KEYS, PHASE_ORDINAL, MASTERED_PCT = 65, WEAK_PCT = 20, resolveGapRingState, FIRST_UNLOCKED_RANK = 1 and the same paywall-on-locked-tile behavior. Analytics strings are duplicated as named constants (PRACTICE_HUB_EVENTS) rather than imported, since @kwilo/analytics is not a mobile dependency today.
Where it diverges¶
| Divergence | Detail |
|---|---|
| No wizard | There is no /practice/start equivalent. app/(app)/practice/ contains only session.tsx and test/[id].tsx. |
| Face A's primary CTA goes to chat | router.push(ROUTES.chat) instead of the wizard (FaceAEmpty.tsx:73). The code says so explicitly: "Mobile has no practice wizard yet, so Start practice falls back to the AI tutor." |
HonestEmptyTile CTA goes to chat |
Same fallback (HonestEmptyTile.tsx), where web routes to the wizard. |
| Only 2 of the 4 events fire | practice_hub_priority_tile_clicked and practice_hub_exam_target_selected. The two wizard_* events have no mobile equivalent, so the wizard funnel is web-only. |
| No catalog usage | Mobile never calls /practice-hub/catalog. It has no consumer for it without the wizard. |
| Layout | The priority map is a single-column stack (web is a 3-up grid at sm+), with a FadeInView entrance stagger. The habit strip is a horizontal ScrollView with a LinearGradient right-edge fade, added so the third item's clip reads as an affordance rather than broken layout. |
The practical consequence: on mobile, a learner with no exam target has exactly two moves, pick JEE or pick NEET, or fall into an unstructured chat. The exam-agnostic path that the wizard exists to provide does not exist there. That is the main parity gap to close.
Non-goals¶
- A wizard that starts a multi-chapter session. Step 2 is multi-select but only the first chapter opens a session. Broadening the player's input is a separate change.
- Ranking the wizard's chapter chips by priority. They are sorted by raw
pyqCount. The wizard is a picker, not a plan. - Making the subject tabs data-driven. They stay a const map per exam family so an empty subject is visibly empty rather than silently absent.
- A real streak.
streakDaysis 0 for everyone. The strip renders it anyway. - Mobile wizard parity. Explicitly deferred in code comments, not forgotten.
Rejected alternatives¶
- Force an exam choice before showing the Practice tab. This is what the pre-two-face hub effectively did, and it is what Face A would become if the wizard CTA were removed. Rejected: it makes JEE/NEET a toll gate on a surface that should serve every learner, and it produces a worse guess than the learner's own free text would.
- A Zod schema factory for the
hasBankconditional. Rejected in the code's own comment (schema.ts:4-9): the factory would need to run before the form and the watch it depends on exist. One imperative gate in one handler was judged simpler than a factory threaded through the orchestrator. - Hide subjects with no bank from the tabs and from the wizard. Rejected: absence is indistinguishable from a bug. An honest empty tile that offers a way forward beats a subject that quietly does not exist.
- Dead-end the no-bank learner with "coming soon". Rejected. The AI tutor can teach any of these subjects today, so routing there with a pre-composed topic costs nothing and turns a dead end into the product's strongest surface.
- Sort the priority map by the engine's
priorityinstead ofyieldPct. Not chosen, and worth flagging as a live tension: the tiles show a yield-ranked map while the engine ranks byimportance × gap. The grid's rank-1 is therefore the highest-yield chapter, not the chapter the engine thinks you most need. The free-tier unlock follows the grid, and the backend gate follows the same yield sort deliberately so the unlocked tile and the permitted chapter can never disagree.
Related¶
- Practice Hub Engine: where the ranked plan and the catalog come from
- Practice Hub Freemium Gate: the rank-1 free-tier lock, both halves
- Practice Player: what a session tap opens
- B2C Mobile App: the mobile surface inventory
- B2C flow: where the Practice Hub sits in the paid journey