B2C Study Plan: generate a plan, work it lesson by lesson¶
Status: shipped LNO: L. The Study Plan is the B2C learner's structured path through a subject. It turns a chat product into a plan product. Owners: @bhanu49 Last updated: 2026-07-13 Roles affected: B2C free, B2C Pro (
role == b2c_user). B2B roles do not see this surface (routes areRoleGuarded tob2c_user). Primary routes:/studyplan,/studyplan/create,/studyplan/:planId,/studyplan/:planId/edit,/studyplan/:planId/lesson/:sequenceSource of the ask: self-initiated product hypothesis (@bhanu49). Exam-prep and syllabus learners want more than an answer per question, they want a sequence. Not a user-interview finding, flagged honestly below. Related: Practice Hub & Study Plan: Freemium Gate, Study Plan (Vision), Textbook grounding, Practice Hub Engine, Two-tier pricing
TL;DR¶
A learner picks a subject, a set of chapters and a schedule, and Kwilo generates a study plan: chapters ordered, scheduled across weeks, each one hydrating into a readable lesson with an inline quiz. The surface is three levels deep: plans list (/studyplan), plan detail (/studyplan/:planId), lesson (/studyplan/:planId/lesson/:sequence).
Study Plan is not Pro-only. Free B2C learners get one active plan; Pro gets three. The lever is the concurrent-plan cap, not the feature. Archiving a plan frees a slot, so a free learner can rotate one subject at a time. Editing a plan reuses its own slot and is never cap-gated.
Why this exists¶
Who asked for it¶
Self-initiated by @bhanu49. No support ticket, no interview transcript. The corroborating signal is behavioural: learners were using chat to ask "what should I study for JEE Chemistry", which is a planning question that chat answers badly, one turn at a time.
What user pain does it solve¶
A learner with a syllabus and a date knows the goal ("finish Class 11 Chemistry before the term test") but not the sequence. Chat answers whatever is in front of them. It does not turn a subject plus a deadline into an ordered, dated, finishable list. The learner ends up deciding what to study every single day, which is the decision they were least equipped to make.
Cost of not doing it¶
Kwilo stays a question-answering toy. Learners come for an answer and leave, and there is nothing to return to tomorrow. Retention has no anchor: no "you are on week 2 of 6, next up is Thermodynamics".
Is the pain validated or a guess?¶
Partly validated, partly a guess. The planning demand is visible in chat usage. The specific shape (generated chapter sequence, schedule, per-chapter lesson and quiz) is a product bet. The free-tier shape (one plan rather than zero) is a newer bet still: it replaced a Pro-only gate that put a paywall on first contact, before the learner had any proof the plan was worth paying for.
How we know it worked¶
A learner opens /studyplan on a later day and completes the next lesson, rather than generating one plan and never returning. Counterfactual: if we removed Study Plan a week after a learner generated one, they would notice immediately, because it is the only surface that holds their place.
What a learner sees¶
Level 1: the plans list (/studyplan)¶
StudyPlanPage is the plans list, and it is the default /studyplan view. There is no separate "today" dashboard. Zero plans renders a real EmptyState with a create CTA, never a surprise redirect into the create form. Each plan renders as a PlanCard with an edit affordance and an ArchivePlanControl.
Level 2: the plan detail (/studyplan/:planId)¶
StudyPlanDetailPage shows a PlanHeader (with "Edit plan") and three tabs, weekly by default:
| Tab | Component | Shows |
|---|---|---|
| Weekly | WeeklyTab |
The multi-week schedule; sessions render as PlannerSessionCard |
| Syllabus | SyllabusView |
Every chapter with its state: done, current, generating, locked |
| Progress | ProgressStats |
Completion across the plan |
Chapters hydrate in a background task after the plan is created, so a freshly generated plan shows generating chapters that fill in.
Level 3: the lesson (/studyplan/:planId/lesson/:sequence)¶
StudyPlanLessonPage is the inline lesson player. The chapter's generated content renders as LessonBlocks, its quiz as LessonQuiz, and submitting the quiz writes progress in place. There is no redirect into the Practice Hub. computeLessonNav gives the learner a forward path ("next lesson") so the plan is walkable end to end.
Edit is a rescope, not a settings dialog¶
/studyplan/:planId/edit renders the same page component as create (StudyPlanCreatePage), pre-filled from the plan's current scope and schedule, with the subject locked. Saving calls the update mutation, which rescopes and regenerates the plan in place, then returns the learner to the plan detail. Because an edit reuses the plan's own slot, it is never cap-gated: a free learner sitting at their one-plan cap can still re-scope the plan they have.
Routes¶
Source: apps/web/src/constants/routes.ts (lines 152 to 158) and apps/web/src/routes/ProtectedRoutes/StudentRoutes.tsx.
| Route | Constant | Page |
|---|---|---|
/studyplan |
ROUTES.studyPlan |
StudyPlanPage (plans list, the default view) |
/studyplan/create |
ROUTES.studyPlanCreate |
StudyPlanCreatePage |
/studyplan/plans |
ROUTES.studyPlanList |
Redirects to /studyplan (old bookmarks) |
/studyplan/:planId |
ROUTES.studyPlanDetail(planId) |
StudyPlanDetailPage |
/studyplan/:planId/edit |
ROUTES.studyPlanEdit(planId) |
StudyPlanCreatePage in edit mode |
/studyplan/:planId/lesson/:sequence |
ROUTES.studyPlanLesson(planId, sequence) |
StudyPlanLessonPage |
Every route is wrapped in RoleGuard allowedRoles={[ROLES.B2C_USER]}.
Retired: /studyplan/week and its StudyPlanWeekPage (with DayStrip, SessionList, MoveSkipDialog) were deleted in PR #1295. The weekly view is now a tab inside the plan detail.
The real gating: an active-plan cap, not a paywall¶
Free B2C learners can generate a study plan. What is capped is how many plans they can hold at once.
| Tier | Concurrent active plans | Constant |
|---|---|---|
| Free B2C | 1 | FREE_MAX_ACTIVE_STUDY_PLANS = 1 (apps/backend/src/services/generated_study_plan.py:87) |
| Pro B2C, and every non-B2C role | 3 | MAX_ACTIVE_STUDY_PLANS = 3 (apps/backend/src/services/generated_study_plan.py:83) |
max_active_study_plans(user) (generated_study_plan.py:109-111) resolves the cap per user via is_free_b2c_user. "Active" means status generating or ready; archived plans do not count, so archiving frees a slot.
End to end, for a free learner¶
- Opens
/studyplan. Sees the real plans list. No lock, no blur, no paywall. - Opens
/studyplan/create, picks subject, chapters and schedule, generates. ThePOSTsucceeds. The backend does not consult the Pro gate.apps/backend/src/api/v1/study_plan.py:203says so explicitly: "Study Plan is not Pro-gated: free users may hold one active plan." - The cap is enforced atomically inside the service (advisory-lock re-check) and surfaced as HTTP 409 Conflict (
study_plan.py:275-277,generated_study_plans.py:209-211), never 402. - Back on the create page with one plan already active,
atCap(activeCount >= maxActive, from the plans list response) disables the generate button and shows thestudyPlan.generatedPlans.capReachedcaption. A 409 that slips through anyway becomes a toast, not a paywall. - Archiving the plan (
ArchivePlanControl) frees the slot; progress is kept server-side.
Reads are open to every b2c_user. In-plan lesson content is not pool-metered. The pool-metered neighbour is the standalone "Understand a topic" read (POST /study-plans/chapter-read, QuotaFeature.CHAPTER_READ, generated_study_plans.py:374), which is a different entry point.
The Practice Hub half of the freemium model (rank-1 chapter free, the rest locked) is unchanged and is documented in Practice Hub & Study Plan: Freemium Gate.
Known inconsistency: the quota payload still says locked¶
This is real and unfixed. It is recorded here so it is tracked, not papered over.
B2CQuotaService.get_quota_status still returns, for a free user (apps/backend/src/services/b2c_quota.py:375):
"study_plan": {
"locked": True,
"required_plan": B2CPlan.PRO.value,
"upgrade_url": B2C_UPGRADE_URL,
},
and the module and enum docstrings still claim "STUDY_PLAN / LESSON_PLAN are Pro-locked regardless of pool state" (b2c_quota.py:10, :65, :209).
What actually happens: nothing. The payload field lies, but nothing enforces it and nothing reads it for Study Plan.
- Enforcement.
B2C_PRO_LOCKED_FEATURESis nowfrozenset({"lesson_plan"})(apps/backend/src/models/b2c_quota.py:47);study_planwas removed from it in PR #1295. Even if a caller rancheck_and_increment(user, QuotaFeature.STUDY_PLAN), it would fall past the Pro-locked branch (b2c_quota.py:235), fail the_POOL_FEATURESmembership test, and return silently (b2c_quota.py:241-242). It is a no-op. - Callers. No study-plan route calls
check_and_incrementwithQuotaFeature.STUDY_PLAN. The only quota call ingenerated_study_plans.pyisCHAPTER_READ. - Frontend.
apps/web/src/services/b2c-quota/schemas.tsstill typesstudy_planas a lock shape, but no B2C surface readsstudy_plan.lockedto gate Study Plan.StudyPlanPagedoes not calluseB2CProGate()at all.useB2CProGate()survives, correctly, for the Practice Hub, which does gate on it.
Residue to clean up (not done yet):
b2c_quota.py: thestudy_planlock dict inget_quota_status, plus the three stale docstrings.apps/web/src/pages/b2c/StudyPlan/components/LockedStudyPlanPreview/: still in the tree with no importers. The blurred-preview-behind-paywall surface it renders is unreachable.StudyPlanCreatePage: still catchesextractUpgradeRequiredErroron plan create and opens thePaywallModal. That branch is dead for plan creation, since the backend never returns 402 there. The same modal is still live for the upload-limit error path, so the modal itself stays.
Where it lives¶
Frontend (apps/web/src/pages/b2c/StudyPlan/):
index.tsx:StudyPlanPage, the plans list.StudyPlanCreatePage/: create and edit.components/ScheduleForm,components/PrefsMetaBar,components/NoPrefsState,components/SelectionSection/(SubjectSelect,ChaptersDropdown,AttachmentPicker,PromptField).StudyPlanDetailPage/:components/{PlanHeader, PlanTabs, ProgressStats, SyllabusView, WeeklyTab}.StudyPlanLessonPage/:components/{LessonBlocks, LessonQuiz},helpers.ts(computeLessonNav).components/:PlanCard,PlannerSessionCard,ArchivePlanControl, and the unusedLockedStudyPlanPreview/.apps/web/src/services/generated-study-plan/:useStudyPlans,useStudyPlanDetail,useStudyPlanChapter,useSubmitTopicQuiz,useArchiveStudyPlan.apps/web/src/pages/b2c/hooks.ts:useB2CProGate()(Practice Hub only now).
Backend:
apps/backend/src/api/v1/study_plan.py: create, update (rescope), draft. 409 on cap.apps/backend/src/api/v1/generated_study_plans.py: generate, list (returnsactiveCountandmaxActive), detail, chapter lookup, chapter-read, quiz submit, archive.apps/backend/src/services/generated_study_plan.py:GeneratedStudyPlanService, the caps,StudyPlanLimitExceededError, background hydration.apps/backend/src/services/b2c_quota.py: the quota service (see the inconsistency above).
Non-goals¶
- A "today" dashboard. The plans list is the default view. A separate L0 dashboard added a level without adding information.
- Pro-only plan generation. Removed in PR #1295. See rejected alternatives.
- Moving or skipping individual sessions. The
MoveSkipDialogfrom the old week page is gone. Rescope the plan instead. - Metering in-plan lesson reads against the generation pool. Lessons inside a plan the learner already generated stay free to read.
Rejected alternatives¶
- Pro-only generation with a blurred free preview (the shipped model until PR #1295). A free learner hit a paywall before ever seeing a plan work, so the paywall had to sell an abstraction. Replaced by "free gets one real plan": the learner experiences the artifact, and the upgrade sells more of something they already value. The blurred preview (
LockedStudyPlanPreview) is the fossil of this model, still in the tree, unwired. - A nav-level lock (the
study_plan_pro_gateplatform flag plus therequire_pro_plandependency, added in PR #1204, removed in PR #1267). A free learner clicked the sidebar entry and got a modal, never the page. It made the product feel smaller than it is. See the retired b2c-study-plan-gate.md. - Unlimited free plans. No upgrade pressure at all, and generation is expensive. The cap keeps cost bounded while keeping the feature honest.
- A lifetime plan cap instead of a concurrent cap. Would punish a learner who finishes a subject. The concurrent cap plus archiving lets a free learner work through subject after subject, one at a time.
Changelog¶
- 2026-07-13: rewritten against the code. Corrected the free tier (one active plan, not a blurred Pro-only preview), replaced the stale route table (
/studyplan/weekis gone), documented the three-level flow and edit-as-rescope, and recorded theb2c_quotalocked: trueinconsistency. - 2026-07 (PR #1295): free tier gets one active plan;
study_planremoved fromB2C_PRO_LOCKED_FEATURES; legacy weekly surface retired. - 2026-07 (PR #1288, #1289): edit-as-rescope, then the three-level flow (list, detail, lesson) with a multi-week weekly tab.
- 2026-06 (PR #1267): nav-level Pro gate removed in favour of inline plus backend gating.