# Onboarding Revamp — Analysis & Implementation Plan

> Status: **Proposed** · Author: planning pass · Scope: `frontend/app/onboarding`, `OnboardingController`, `RoadmapService`, `learner_profiles`, assessment wiring.

This document captures (1) a full analysis of the onboarding feature as it exists today, (2) the inconsistencies and weaknesses found, and (3) a concrete, phased plan to turn onboarding into a **story-driven, personalized journey** that always starts every learner at **N5**, while letting strong learners *earn* a skip to N4 through the real Final Assessment.

---

## 1. How onboarding works today

### 1.1 Flow

The wizard lives in [`frontend/app/onboarding/page.tsx`](../../frontend/app/onboarding/page.tsx) as a 4-step state machine (`Step = 0 | 1 | 2 | 3`):

| Step | Screen | Captures | Effect |
|------|--------|----------|--------|
| 0 | **Goal** | `goal_type` (`jlpt` / `speaking` / `both`) + `target_level` (N5–N1, only if JLPT-related) | Sets the destination level |
| 1 | **Pace** | `pace` (`casual` / `steady` / `intense`) | Daily new-item target (5 / 10 / 20) |
| 2 | **Kana** | `kana_known` (bool) | Routes absolute beginners to Kana Academy |
| 3 | **Placement** | 3 MC meaning questions per level (N5→N1), client computes `current_level_estimate` | Estimated starting level |

On finish → `POST /api/v1/onboarding/complete` persists the profile, stamps `onboarded_at`, and calls `RoadmapService::assignPath()`.

### 1.2 Backend surface

- **Controller:** [`OnboardingController`](../../backend/app/Http/Controllers/OnboardingController.php)
  - `state()` — drives the route guard (`onboarded: false` → wizard).
  - `placement()` — builds the MC quiz from the `cards` pool (3 per level), **includes the correct answer in the payload** (self-estimate, no security impact).
  - `complete()` — validates + persists; clamps `current_level_estimate` to never exceed `target_level`; calls `assignPath()`.
  - `reset()` — clears the completion stamp + path marker, keeps study progress.
- **Routes:** [`backend/routes/v1/roadmap.php`](../../backend/routes/v1/roadmap.php) lines 14–17.
- **Model / table:** [`LearnerProfile`](../../backend/app/Models/LearnerProfile.php) over `learner_profiles` (migration `2026_06_08_000001`). Columns of interest: `goal_type`, `target_level`, `current_level_estimate`, `pace`, `target_date`, `kana_known`, `active_path_id`, `current_stage_id`, `onboarded_at`.
- **Roadmap brain:** [`RoadmapService`](../../backend/app/Services/RoadmapService.php)
  - `pickPath()` (lines ~775–780) — **already hardcodes N5 for everyone** (`LearningPath::where('target_level', 'N5')`).
  - `journeyFor()` (line ~543) — uses `current_level_estimate` as the journey's `$startOrder`.
  - `syncProgress()` / `nextPath()` — already chain N5 → N4 → … up to the target as stages complete.
- **Assessment engine:** [`AssessmentService`](../../backend/app/Services/AssessmentService.php)
  - `buildFor($user, $level)` — server-frozen snapshot exam (vocab/kanji/grammar/reading/listening), graded server-side, anti-memorisation batch rotation, post-fail cooldown.
  - `hasPassed($userId, $level)` — the trusted gate the roadmap already respects.
  - Routes: `GET /assessments/{level}` + `POST /assessments/{level}/submit`.

### 1.3 Mascot inventory

- Four candidate stylised mascots (Daruma, Tanuki, Kitsune, Neko) live as inline SVGs in [`frontend/app/mascot-preview/page.tsx`](../../frontend/app/mascot-preview/page.tsx) — none wired into onboarding.
- The **robot mascot** used by the **Kanji game mode** is an inline SVG inside [`frontend/components/games/McqRunner.tsx`](../../frontend/components/games/McqRunner.tsx) (~lines 212–242): teal head, yellow pulsing antenna, cyan eyes, speech-bubble companion. **This is the chosen face for onboarding.** Same robot also appears in `practice/[wordId]` and `battle/[id]`.
- "Naebara" in the current onboarding copy is **text only** — no asset behind it.

---

## 2. Findings — what's wrong / weak today

### 2.1 🔴 Live inconsistency: placement vs. N5-start (must fix)

The "everyone starts at N5" rule is **half-implemented in a contradictory way**:

- `pickPath()` ignores the placement estimate and always returns the N5 path.
- **But** `complete()` still persists `current_level_estimate`, and `journeyFor()` still reads it as `$startOrder`.

Result: a learner who tests into N3 gets an **N5 starting path** but a **journey progress bar that begins at N3** — the N5/N4 segments silently vanish from their visualization. The docblock above `pickPath()` still describes the *old* "start = estimate" behavior, so the code lies about itself. This is a real bug, not just dead code.

### 2.2 🟠 No emotional hook

Step 0 opens cold with a form question ("Apa yang membawamu kemari?"). Nothing frames learning Japanese as a *journey* the user is about to embark on. No mascot, no narrative, no sense of place.

### 2.3 🟠 Placement is the worst-feeling step

15 multiple-choice questions **before any value is delivered**, and — per §2.1 — it currently barely affects the product (everyone starts N5 anyway). High friction, low payoff.

### 2.4 🟠 Thin personalization

Only 4 signals captured (goal, target, pace, kana). Nothing about *why* the learner is here, how much time they have, their prior experience, what to call them, or how they like to learn — all of which could feed the dashboard, AI conversation topics, and example-sentence flavor.

### 2.5 🟡 Mixed-language copy

Indonesian UI with leftover English strings (e.g. error messages, `"Aku pemula — lewati seluruh test"`). Should be consistently Indonesian.

---

## 3. Goals for the revamp

1. **Story-first.** Open with a multi-panel narrative (robot mascot) that frames the N5→N1 climb as an adventure.
2. **Always start at N5.** Enforced and *consistent* across path assignment **and** the journey visualization.
3. **Richer personalization.** Add 5 questions: name, motivation, prior experience, daily time budget, favorite learning method.
4. **Re-purpose placement as an opt-in "skip-ahead challenge"** (see §5) — never a barrier, always a bonus.

---

## 4. The "start at N5, earn N4" model

### 4.1 Design

This is the key idea and it maps cleanly onto **existing** infrastructure. Two-gate funnel:

```
Story → Profile quiz → [Optional] Placement quiz (lightweight, 6 Qs)
                                   │
                          score ≥ 90%?
                                   │ yes
                                   ▼
              Offer: "Take the real N5 Final Assessment now"
                                   │ accept
                                   ▼
                 AssessmentService::buildFor(user,'N5')  ← real, server-graded exam
                                   │ pass
                                   ▼
        current_level_estimate = N5, N5 marked passed (hasPassed=true)
        → roadmap chains the active path forward to N4 automatically
                                   │
                   Learner BEGINS the journey at N4.
```

If the learner **declines** the challenge, **scores < 90%**, or **fails** the real exam → they simply start at **N5** (the default). No penalty, no dead end.

### 4.2 Why split into two gates (important refinement)

The lightweight placement quiz score **must not by itself** promote the learner. It only **unlocks the offer**. Promotion happens **only** when the learner passes the **real N5 Final Assessment**, because that exam is:

- server-graded against a frozen snapshot (no client-trust),
- drawn from curated, anti-memorisation batches,
- the exact same `hasPassed('N5')` gate the rest of the roadmap already honours.

So a lucky 6-question guess can never skip N5. The placement quiz is just a low-stakes "are you ready to try the real thing?" filter. This keeps the integrity of the roadmap intact while giving strong learners a legitimate fast-track.

### 4.3 How "start at N4" actually happens (no new engine code)

Once `AssessmentService::hasPassed(user, 'N5')` is true:

- `RoadmapService::syncProgress()` already treats the **N5 final stage as gated by the N5 assessment**. With the assessment passed and N5 stage objectives… here's the nuance ↓

There are **two implementation options** for making a fresh learner who passed the N5 exam *begin* on N4:

- **Option A — promote `current_level_estimate` and have `pickPath()` honour it as a floor.** Change `pickPath()` to: start at N5 **unless** `hasPassed('N5')`, in which case start at the N4 path (clamped to target). Minimal, explicit, easy to reason about. **Recommended.**
- **Option B — auto-complete the N5 path on pass.** Mark all N5 stages complete so `syncProgress()` chains to N4. More side effects (milestone rewards would fire for skipped stages, progress bars show N5 as "done" without study). Not recommended for a *skip*; better reserved for genuine study completion.

**Plan adopts Option A.** It expresses intent precisely: "passing the N5 exam at onboarding lets you *begin* at N4," without fabricating N5 study history.

---

## 5. Implementation plan

### 5.1 Backend

**Migration — `learner_profiles` add columns** (all nullable strings):

| Column | Values | Feeds |
|--------|--------|-------|
| `display_name` | free text | Mascot addresses the learner by name |
| `motivation` | `anime` / `travel` / `work` / `family` / `culture` / `jlpt` | AI convo topics, example-sentence flavor |
| `time_budget` | `5` / `15` / `30plus` (minutes/day) | Pace tuning, streak expectations |
| `experience` | `new` / `some` / `rusty` | Encouragement copy, kana fast-track |
| `learning_style` | `reading` / `listening` / `speaking` / `games` / `flashcards` | Surface preferred activities first |

Keep `current_level_estimate` (now written **only** on a passed N5 onboarding exam; default stays null = start N5).

**`LearnerProfile` model** — add the 5 columns to `$fillable`.

**`OnboardingController`:**
- `placement()` — **keep**, but **shrink to a lightweight skip-ahead quiz**: ~6 questions weighted toward N5/N4 meaning recognition. Still returns the answer for client scoring (it's only a gate to the real exam, no integrity impact). Add the threshold (`SKIP_AHEAD_PCT = 90`) to the payload so the client/UI stay in sync.
- `complete()` — add validation + persistence for the 5 new fields; **stop accepting `current_level_estimate` from the client**. The estimate is now set server-side, derived from `AssessmentService::hasPassed($user,'N5')` at completion time.
- New behaviour: after the profile is saved, if `hasPassed('N5')` → set `current_level_estimate = 'N5'` before `assignPath()` so `pickPath()` can promote to N4.

**`RoadmapService`:**
- `pickPath()` (Option A) — start path = N4 **iff** `current_level_estimate === 'N5'` **and** `AssessmentService::hasPassed('N5')`; else N5. Clamp to `target_level`. **Fix the stale docblock.**
- `journeyFor()` — **force `$startOrder` to N5 (0)** always, so the journey bar is consistent with the N5-floor model regardless of any estimate. (Resolves §2.1.)

**Routes** — no removals needed; `placement` stays. Assessment routes already exist (`/assessments/{level}` + submit), so the "take real N5 exam" flow reuses them directly. No new endpoints required.

### 5.2 Frontend

**`frontend/components/ui/mascot-robot.tsx`** (new) — extract the robot SVG from `McqRunner.tsx` into a reusable component (props: `size`, optional speech-bubble children, float animation). Reused by onboarding story panels and re-usable elsewhere.

**`frontend/api/services/onboarding.ts`:**
- Extend `LearnerProfile` + `CompleteOnboardingInput` with the 5 new fields; remove `current_level_estimate` from the *input* type (still present on the profile read model).
- Keep `getPlacement` (now lightweight). Add a thin helper or reuse the existing assessment service client for the real N5 exam.

**`frontend/app/onboarding/page.tsx`** — rewrite the step machine into phases:

1. **Story phase (new)** — 3–4 swipeable panels, robot mascot + evocative copy:
   - P1: welcome + the robot introduces itself, addresses the journey ahead.
   - P2: the climb — N5 at the foot of the mountain, N1 at the summit.
   - P3: "I'll walk with you" — sets the companion tone; "Mulai" enters the quiz.
2. **Profile quiz phase** — ordered lightest-first:
   `Name → Motivation → Experience → Goal/Target → Pace → Time budget → Learning style → Kana`.
   - Remove client-side `estimateLevel` / `clampToTarget` (estimate is now server-derived).
3. **Skip-ahead phase (re-purposed placement)** — after the profile quiz, offer an **optional** "Tantangan: lewati ke N4?" card. If accepted, run the 6-Q lightweight quiz:
   - **< 90%** → friendly "Mulai dari N5" and finish.
   - **≥ 90%** → "Kamu siap! Ambil Ujian Akhir N5 sekarang?" → launch the **real** N5 assessment flow.
     - **Pass** → finish; backend promotes start to N4.
     - **Fail / cooldown / not-ready batch** → graceful fall-back to N5 start (reuse existing cooldown/`ready:false` handling).
4. **Finish** — submit all fields → dashboard.

- Fix all mixed-language copy to consistent Indonesian.

### 5.3 Sequencing (suggested PRs)

1. **PR1 — Backend foundation:** migration + model fillable + `journeyFor` N5 fix + `pickPath` Option A + `complete()` server-side estimate + lightweight `placement()`. (Ships the consistency fix and skip-ahead capability behind the API.)
2. **PR2 — Mascot extraction + story phase + profile quiz rewrite** (the visible UX win; skip-ahead offer can be stubbed to "always start N5" until PR3).
3. **PR3 — Skip-ahead wiring:** placement → real N5 assessment → N4 start, with all fall-backs.

---

## 6. Open questions / decisions captured

- **Placement scope:** kept (per product owner) but re-scoped to a 6-question *gate to the real exam*, not a level estimator. ✅
- **Skip target:** only **N5 → N4** at onboarding (single-step skip). Multi-level skip-ahead intentionally out of scope — start the journey, then climb.
- **Mascot:** the **Kanji-mode robot** (`McqRunner.tsx`). ✅
- **New questions:** name, motivation, time budget, experience, learning method. ✅
- **`current_level_estimate`:** retained, now server-authored (only set to `N5` on a passed onboarding N5 exam).

---

## 7. Risks & mitigations

| Risk | Mitigation |
|------|-----------|
| No active N5 assessment batch → skip-ahead can't run | Existing `ready:false` path already handled; UI falls back to "Mulai dari N5". |
| Strong learner annoyed by extra steps | Skip-ahead is fully optional and after the core quiz; default path is unchanged. |
| Promotion side-effects (milestones firing for N5) | Option A avoids marking N5 stages complete — no spurious rewards. |
| Journey bar regressions | `journeyFor` now pinned to N5 floor; add a test asserting start order is always N5. |
