# DMAIC 之路 — the Road (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.road.*` (`GET /property/academy/road/{stage}`,
`GET /property/academy/road/next`, `PUT …/{stage}/card`, `POST …/cycles`, `POST …/handoff/wealth`);
`{stage}` is constrained to `mindset|define|measure|analyze|improve|control|takeoff` on BOTH the GET
and the PUT ·
**Nav:** "Learning Hub" → **DMAIC 之路** tab (the hub's FIRST tab, `?tab=road`); the stage and terminal
pages are DETAIL pages of that tab (back link "← DMAIC 之路", no section masthead — GUIDELINES §15).
Manage: Leads → Show → **Property Portal → DMAIC 之路** pill (advisor's brief, read-only, shown only
for a lead with ≥ 1 cycle; the same pill in the read-only `LeadDetailModal`) ·
**Gated by:** the portal group (`['auth','main','contact.verified']`) — nothing inside the road is a gate.

> ✍️ **Before touching any copy in `content.js` / `takeoffContent.js` / `stages.js`, read
> [content-rules.md](/docs/modules_handbook/main/dmaic-road/content-rules.md)** — the first-time
> reader rules (define on first use, no forward references, one cast introduced before use, a story
> is a strip, one idea per screen…). They came from the founder's 2026-09-02 read of 起 · screen 2,
> and the review behind them found the same defect on every stop.

## What it does

A guided path through **ONE property purchase**, walked one screen at a time, that ends in a card the
member fills at every stage and hands those cards to the portal's tools. The road is

**起 · Mindset → D · Define → M · Measure → A · Analyze → I · Improve → C · Control → T · Takeoff → 终点 (→ 下一轮)**

— seven stops plus the terminal screen, so **eight segments** on the path map.

- **起 · Mindset** became the road's FIRST STOP on 2026-08-28 (the founder, reading the hub tab: *"this
  page has too much information … add one more chapter before Define called Mindset"*). Until then it
  was the hub's un-numbered intro; now it is a stop with a page and a card of its own — five screens
  (为什么 · ESBI · 杠杆 · DMAIC 的地图 · 我们给你的工具) ending in ONE boolean, `acknowledged`:
  「我准备好了，从 D 开始」. **这一格不留数字，只留一句承诺.**
  `JourneyCard::STAGE_MINDSET` is **0** — the existing `unsignedInteger` `stage` column already took it
  (so nothing was migrated), and 0 sorts FIRST through every `array_keys(STAGES)` loop, which is the
  whole point: no loop that walks the road had to learn a new stop exists.
  ⚠️ **Never test a stage with `if ($stage)`** — 0 is falsy. Compare against `null`
  (`JourneyCard::stageFromKey()` returns `null`, not `false`, for a key the road does not have).
- **D · M · A · I · C** are the DMAIC letters — the course's own method, recut onto course 4's chapters.
  Each stage page is a **nine-beat stepper** (问题 · 概念 · 方法的答案 · 常见误区 · Ryan 的数字 · 自己动手 ·
  真实案例 · 你的卡 · 检查与深入) with `?beat=N` in the URL, and ends in the stage's **card** —
  server-stored, lead-owned, every field nullable ("我还不知道"). The next stage consumes the previous
  card (I's instalment needs M's price ceiling; I's area picker lists A's areas).
- **T · Takeoff** is the **closing unit**, not a sixth letter: it lifts the camera from P (the property
  engine U1–U6 taught) to **2A1P** — A1 the day job you keep, A2 an AI second active income built with
  vibe coding + government grants, P one rent-producing property — and the ORDER law (part-time first;
  full-time is a result). Seven screens of its own (countdown · death valley · three engines · two doors
  · order law · course map · the 72-hour action list = the T card). It lands on portal pages that
  already exist: `/ai/vibe-coding`, `/ai/grant-application`, Wealth Planning.
- **终点 (→ 下一轮)** re-runs the cards through the maths and shows one of three honest states from the
  Control card's dates — not bought (the default; the numbers are 推算) · SPA signed / handed over (DSR
  spent, zero rent, an unlock month for round 2) · rented (I's predictions beside C's actuals) — plus
  the A2 line from the T card in every state, and "开始第 2 轮", which closes the cycle and seeds D′.

**The letters name the JOURNEY.** Nothing inside a tool is ever labelled D/M/A/I/C (spec §11.3).
**Every derived number is computed on the client** (`utils/road/math.js`, on top of Wealth Planning's
`eligibilitySnapshot()` / `computeInstallment()`) and **never stored** — the same rule as the Wealth
Planning canvas, so the card, the strip, the terminal screen and the advisor's brief can never disagree.
Every figure is a teaching calculation, and every page says so: never loan approval, never advice.

Since **Phase 2** (2026-08-28) the traffic also runs the other way: the three tools answer to the road
— see [Phase 2 — the road reaches into the tools](#phase-2--the-road-reaches-into-the-tools).

## How it works

**Data.** `learning_journeys` is one CYCLE per purchase (`lead_id` + `cycle_no` unique; a lead has one
LIVE cycle = the highest `cycle_no`, every earlier one is CLOSED and kept). `learning_journey_cards`
holds one row per stage (`journey_id` + `stage` unique) with the member's INPUTS as JSON `payload` and a
`completed_at`. No schema FKs; `HasUuid` + `RecordsBlame` on the journey; the card is a child row (no
uuid — the stage key + the journey are its address).

- **Status is READ OFF the Control card**, never driven by the member:
  `LearningJourney::statusFromControlPayload()` → `rented_at` ⇒ RENTED, `vp_at` ⇒ HANDED_OVER, `spa_at` ⇒
  SIGNED, else IN_PROGRESS; CLOSED once a later cycle exists (never downgraded).
- **`JourneyCard::STAGES`** is the one list of stops — **0 Mindset, 1–5 DMAIC, 6 Takeoff**, in that
  array order (`DMAIC_STAGES` is the five letters proper, and neither 起 nor T ever joins it);
  `PAYLOAD_FIELDS` + `normalizePayload()` whitelist what a card stores per stage, so a computed field or
  a stray key from a stale client never reaches the JSON column; `KEY`s are public identity (in every
  URL and prop) — never rename one. `PAYLOAD_FIELDS[0]` is the single key `['acknowledged']`, and
  `normalizePayload()` coerces it to a REAL bool (the Form Request's `boolean` rule also admits `1` /
  `"1"`), so the client's `acknowledged === true` and the server's `=== true` agree; `null` stays null —
  a hollow card is "never pressed", not "refused".
- **Completion is decided twice, and the server can only DOWNGRADE.** The client sets `completed` on
  submit from `math.js completeness()`; `LearningJourneyRepository::saveCard()` re-runs the SAME rules
  through `Src\Journey\Support\Completeness` (a pinned PHP twin) and writes `completed_at` only when
  BOTH say yes. A stale bundle, a hand-made request or a rule that has since tightened can therefore
  never paint a path-map segment solid and tell the advisor a decision was made when none was; a client
  "no" is final, because the member may have un-answered something the rules cannot see. Mindset's rule
  is `acknowledged === true`, strictly — a `false` is a member who has not pressed it.
- **Writes** go through `LearningJourneyRepository` only (`start()` — next `cycle_no`, closes the
  previous cycle in the same transaction under `lockForUpdate()`, optionally seeds the Define card;
  `saveCard()` — upsert, keeps the first `completed_at`, re-derives status on a Control save and mirrors
  it into the pinned Wealth Plan; `attachWealthPlan()`). A journey is created **lazily on the first card
  save** — never on a GET — so browsing leaves no rows behind.
- **Reads** are shaped by ONE presenter, `Src\Journey\Support\RoadPresenter`
  (`journey()` / `cards()` / `stages()` / `staleness()` / `previous()` / `cycles()` / `hub()`), used by
  `RoadController`, `CoursesController@index` (the hub's `road` prop) and `LeadsController` show()+quick()
  (the advisor's `road.cycles[]`).
  - **`n` is a POSITION, not the constant.** `RoadPresenter::position()` reads the index out of
    `STAGES`' own order, so every `stages[]` entry carries 起 = 1, D = 2 … T = 7. The two parted the day
    Mindset became stage 0; a position is what a path map or a 「第 n 格」 strip can print, the constant
    is a storage key, and adding a stop anywhere renumbers the rest with no table to hand-maintain.
    > ⚠️ **Known gap (2026-08-28): the singular `stage` prop did not follow.**
    > `RoadController::stage()` still sends `'n' => $stageNo` — the STAGE_* constant — so `Stage.vue`'s
    > two 「第 {n} 格」 lines print **第 0 格** on 起 and **第 1 格** on Define, while the hub's chapter
    > list and the path map call the same two stops 1 and 2. One-line fix:
    > `'n' => RoadPresenter::position($stageNo)`. Nothing else reads `stage.n` — `Stage.vue`'s stale
    > banner walks `ROAD_STAGES` by index (`roadIndex`), not by `n`.
  - **Staleness** — an UPSTREAM card was saved after this one — is computed server-side here with
    exactly the rule `math.js staleness()` uses (transitive by construction: a changed D marks M, A, I
    and C; a missing card is never stale), so the hub's path map (which runs no JS maths) agrees with
    the stage pages. `RoadPresenter::UNDERIVED_STAGES` (**起 and T**) sits outside the rule *on both
    sides*: neither is derived from the cards before it, and neither is an upstream source — a member
    re-pressing 「我准备好了」 a month into the road must not paint D, M, A, I and C amber, because no
    figure on any of them came from that click.
  - **`hub()`'s `continue_href`** is the first stop without a completed card — with one exception: 起 is
    that stop only for a member with **no card at all**. Someone who has a D card but never pressed the
    promise is past 起 by deed, and sending them back to a page of "why" would read as the road
    forgetting their numbers. The decision lives here once; the hub's 继续 label just reads the href.

**Request flow.**
- `GET /property/academy/road/{stage}` → `RoadController@stage` → `Inertia('Main/Portal/Road/Stage')`
  with `stage`, `stages[]` (**seven**, with `completed`/`stale`), `journey` (live cycle + `cards{}`,
  which carries a `mindset` slot like every other stop; additive `status_chinese`, `status_color`,
  `wealth_plan_uuid`), `previous` (last CLOSED cycle, for round-2 seeding), `lessons[]` (this stage's
  REAL lessons from `config/road.php`'s chapter map — course 4 for 起 and D–C, course 8 / the AI class
  for T; the Bonus "没有了 90% Quota" chapter joins Define only on cycle ≥ 2 or ≥ 2 residential loans),
  `course` (access via `Course::isAccessibleTo()`), `hub_href`, `related_courses` (Control only) and
  `prefill` (Improve only — Phase 2, below).
- `PUT …/{stage}/card` → `SaveCardRequest` (rules switch on the route's `stage`; every field nullable;
  dates `YYYY-MM`; enums `Rule::in` on the model's keys; Mindset is the single
  `payload.acknowledged => nullable|boolean`) → explicit mapping `$data['journey_card']['payload']` /
  `['completed']` → repository → `ActivityLog::TYPE_JOURNEY_CARD_SAVED` (53, Learning category,
  coalesced; skipped for staff actors) → `flash()` → `back()` (Inertia answers a PUT's `back()` with
  303 — normal).
- `POST …/cycles` → `StartCycleRequest` (optional seeded Define `payload`) — requires the live cycle to be
  at least SIGNED (a second round of a purchase that never happened is a re-run), closes it, opens the
  next, redirects to D.
- `POST …/handoff/wealth` → builds a Wealth Planning canvas state from the D (+ M) cards
  (`WealthPlanSeed::fromCards()`, key-for-key with `Edit.vue`'s defaults, MoF 90/70 cliff, new-HDA vs
  subsale by route), pins it on the cycle, opens it; a second click reopens the SAME plan (the canvas is
  the source of truth once it exists). The create is wrapped in one transaction with a `lockForUpdate()`
  re-read of `wealth_plan_id`, so a double click cannot mint two plans.
- `GET …/next` → `RoadController@next` → `Next.vue` (stage-less: `stages`, `journey`, `previous`,
  `course`, `hub_href`, `cycles[]`).

**Frontend.**
- **Content is data.** `utils/road/stages.js` — **two lists, on purpose**: `STAGES` / `STAGE_KEYS` are
  the FIVE DMAIC letters (the cycle `CycleWheel` draws, and the contract's fixed set), while
  `ROAD_STAGES` / `ROAD_KEYS` are the road as WALKED — `MINDSET_STAGE`, the five, then `TAKEOFF_STAGE`.
  Everything that walks reads the second: `prevStageKey('define')` is `'mindset'`,
  `nextStageKey('control')` is `'takeoff'`, and only Mindset has no previous / only Takeoff no next.
  `content.js` holds all Chinese teaching copy (the nine beats per stage, `FOUNDATION` +
  `FOUNDATION.screens`, `CONTENT.mindset`, `NEXT`, the option tables `DEMAND_DRIVERS` / `SUPPLY_RISKS` /
  `PROPERTY_TYPES_M` / `ROUTES_M` / `CONTROL_STEPS`, `RYAN` — the ONE worked case every stage reuses);
  `takeoffContent.js` holds `TAKEOFF` (the seven screens, the T card copy + checklist, the three Pro
  cards, the final quiz, `CHARACTERS` 小明/阿强, `T_PROPERTY`). No Vue in any of them.
  **`CONTENT.mindset` is NOT the nine-beat shape**: `{ question, screens: [ …FOUNDATION.screens, tools ],
  cta, deeper }`. Its first four screens are the SAME OBJECTS as `FOUNDATION.screens` (why · esbi ·
  leverage · map) — one copy of the words, so editing them there changes both surfaces — and the fifth,
  `tools`, is the portal map: six cards (`D` Wealth Planning · `M · A · I` Analyze Property · `C`
  Landlord Management · `T` Vibe Coding + Grant · `学 A` Area Guide · `问` AI Coach), carrying the
  one-liners the hub's retired 「四个工具，一条路」 strip used to.
- **Maths is pure.** `math.js` (`defineComputed` · `measureComputed` · `improveComputed` ·
  `controlComputed` · `nextState` · `staleness` · `completeness` · `roundTwoSeed` · `ryanNumbers` …) and
  `takeoffMath.js` (`savingsMonths` · `stressTest` · `cliffVsRunway` · `engineSim`), every function
  null-safe, each with a `*.test.js` pinning Ryan's numbers (DSR 27.7 % → borrowable RM 550,000 → ceiling
  RM 611,000 at 90 %; instalment ≈ RM 2,391 on the RM 540,000 loan; DSR 64.5 % at SPA, 50.3 % once rent
  is seasoned). `completeness('mindset', p)` is `p.acknowledged === true`; `staleness().mindset` is
  always `false`. **If a number on screen disagrees with the copy, the wiring is wrong, not the copy** —
  every insight figure in `content.js` was read off `math.js`.
- **Stage page = stepper.** `Stage.vue` shows one beat at a time (`Partials/Beats/beats.js` +
  `BeatRail`), `?beat=N` in the URL (GlossaryCourse's pushState / replaceState / popstate / strip-on-
  unmount pattern), `<Transition mode="out-in">` honouring motion-reduce, ←/→ except in a field, and a
  `ConfirmModal` when leaving the card beat with unsaved changes (Save is explicit; nothing autosaves).
  **The two stops that are not letters bring their own stepper**: `mindset` mounts
  `Partials/Mindset/MindsetStepper.vue` (five screens) and `takeoff`
  `Partials/Takeoff/TakeoffStepper.vue` (seven). For both, `Stage.vue` rails the SCREENS by `?beat=N`
  exactly as it rails beats, skips the nine-beat frame, skips its own ←/→ listener (each stepper owns
  the keys, or one press would move two screens) and skips the unsaved-card guard; 起 also hides the
  `SummaryBar`, because a member standing on the road's first stop has no numbers to summarise.
  `beatCountOf()` is why 上一步 out of D lands on 起's LAST screen rather than a beat 9 that does not
  exist there. `Stage.vue` walks `ROAD_STAGES` for its neighbours, so no stop is a special case:
  起 has no previous, D's previous is 起, C's next is T, T's next is the terminal screen.
- **Cards** (`Partials/Cards/*Card.vue` on `CardShell` + `cardForm.js`): seeded from
  `journey.cards[stage].payload`, `''`→`null`, numeric strings→numbers, `completed` decided by
  `completeness()` on submit, `form.put(…, { preserveScroll: true })`; every field has an explicit
  我还不知道 affordance; read-outs recompute live. The T card is `Takeoff/ActionListScreen.vue`; the 起
  card is the CTA on the last Mindset screen (a PUT of `{ payload: { acknowledged: true }, completed:
  true }`, then straight on to D).
- **Viz** (`Partials/Viz/`, hand SVG, the validated palette `#2563eb #eb6834 #1baf7a #eda100`, `role="img"`
  + aria-labels, never meaning by colour alone, `useVizReveal` for reduced-motion-aware reveals):
  DsrGauge · LeverageBars · FunnelDiagram · PsfCompare · CashflowWaterfall · ConstructionTimeline ·
  DsrTimeline · CycleWheel, and `Viz/Takeoff/` for T's seven widgets (the two signature ones are
  **TwoDoorsSim** — bank door / government door, 在职 ⇄ 辞职 — and **EngineSimulator** — A1/A2/P over 72
  months with the 学阿强 counter-button). 起's three diagrams (`EsbiQuadrant` · `LeverageVsStocks` ·
  `DmaicMap`) live beside its screens in `Partials/Mindset/Viz/`, because only that stepper draws them.
- **Shared strips** (`Components/Road/`): `PathMap` (one chevron segment per `stages[]` entry plus the
  "→ 下一轮" terminal one — **eight** since 起 joined, and it joined simply by being first in the
  server's list; solid = complete, hollow = not, amber ring + the WORD 已过期 = stale; the badge prints
  the stop's `letter`, so 起 shows as 起, while T draws lucide `Rocket` + `Building2` and the terminal
  `RefreshCw`, never emoji. A completed segment opens on its CARD — for 起 that is its LAST screen, not
  `beats.js`'s beat 8, which the five-screen stepper would clamp), `SummaryBar` (the member's chain in
  one sticky line: D's two numbers → M → A → I → T's A2 candidates; ONE hollow "先填 D" chip when D is
  missing — never nagging copy; 起 carries no figure, so it is not in the chain), `StageHero`.
- **Hub tab** `Lms/Partials/Road/RoadOverview.vue` — **TWO blocks, and that is the whole design**
  (founder, 2026-08-28: *"probably have one hero section then he can choose either from beginning or
  select specific chapter"*):
  1. **Hero** — the road's cover, its name, one line of what it is, and the two ways in: **从头开始**
     (always, → 起) and **继续** (only once a journey exists — a "continue" button for someone who has
     never opened the road is a lie, and two identical buttons is the paralysis this rewrite removed).
     继续 NAMES the stop it lands on (「继续 · D · 定目标 · 定能力」), and it reads that stop off
     `road.continue_href` rather than re-deriving the rule, so the label and the button can never point
     at different stages.
  2. **选章节** — the eight stops as a plain vertical list (起 · D · M · A · I · C · T · 下一轮): letter
     badge (solid = complete, hollow = not, amber ring = stale), name + Chinese, the stop's ONE question,
     a chevron. The **下一轮** row is dimmed and says 「先签约」 until the journey is at least SIGNED —
     dimmed, not hidden, because a member should see the road has an end.
  **The `PathMap` is deliberately NOT on this tab**: the chapter list IS the map here, and one road is
  drawn once per screen. Four things left the tab in the rewrite and each went somewhere better: the
  four-screen 起 mini-stepper became the road's first STOP, the 「四个工具，一条路」 strip became 起's
  fifth screen, and the stage explainer grid + the 「字母只在旅程层」 paragraph were teaching copy on a
  page whose job is to let a member CHOOSE.
- **Terminal** `Next.vue`: three states from `nextState()`, the chain, the three-time-point
  DSR read-out (inline SVG), prediction-vs-actual (rented only), the D′ seed preview, "开始第 2 轮" via
  `ConfirmModal` → `router.post(…/cycles, { payload: roundTwoSeed(cards) })` with the honest unlock month
  (never disabled; absent in not-bought), the A2 line, cycle history. **The `CycleWheel` stays the five
  DMAIC arcs** — the loop is C → D′; 起 is walked once and T rides beside the wheel as a chip, because
  neither feeds D′.
- **Hand-offs into the portal:** 起 → the six tool cards on its last screen (links only, nothing is
  gated); D → `POST …/handoff/wealth`; M → the pinned plan (`wealth_plan_uuid`) or `/wealth-planning`;
  A → `/property/academy?tab=area-guide&area={key}` (`AreaGuidePanel` opens that area on mount);
  I → `/analyze-property/new-projects`, pre-narrowed by the M card's type and the first A-card area the
  guide covers; C → `/property/landlord-management`, `/property-concierge/new`, the two rental courses;
  T → `/ai/vibe-coding`, `/ai/grant-application`, `/wealth-planning`; every stage →
  `/property/ai-advisor?prompt=…` (the AI Coach seeds its composer from `?prompt=`; never auto-sends).

## Phase 2 — the road reaches into the tools

Phase 1 built the path. **Phase 2 (2026-08-28) made the three tools answer to it**, in both directions,
without a single new table. Four pieces:

**1 · Analyze Property → New Project listing: the three-step strip.** `BuildNewProjectListing` grew a
portal-only, Malaysia-only strip that asks the M · A · I questions in that order — **类型** (the M
card's own vocabulary: 居住型高楼 / 有地 / 商业, mapped to catalogue property types by
`BuildNewProjectListing::TYPE_GROUPS`) → **地段** (chips from `config('road.growth_areas')`, resolved
with the catalogue's existing `CatalogProject::scopeNearby()` haversine at
`config('road.growth_area_radius_km')` = 3 km) → **项目质量** (the three sieves `bmv` / `cashflow` /
`dual_key`). Every project card then carries the three verdicts as badges with the numbers beside them,
and a **带进 I 卡** link. The full behaviour — the counts, the empty state that names the step that
emptied the list, and why the filter's SQL and the badge's PHP must be the same rule — is documented
with the listing, in [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md).
`AnalyzePropertyController::newProjects()` reads the member's **D card** (`LearningJourney::currentFor()`
→ the Define card) and passes `dNumbers: { mof, borrowable, priceMax, cycle_no }`, so the cashflow sieve
runs on the member's own MoF (90 or 70) and the strip can say 按你的 D 卡 instead of assuming.

**2 · The project → the I card (`带进 I 卡`).** `improveHref(uuid)`
([utils/road/projectLinks.js](/resources/js/utils/road/projectLinks.js)) builds
`/property/academy/road/improve?beat=8&project={uuid}`; `RoadController::stage()` loads that
`publiclyListed()` row and shapes it through **`Src\Journey\Support\ProjectPrefill`** into the additive
`prefill` prop — `{ source, project_uuid, project, area_key, area_name, asking_psf, median_psf,
expected_rent, maintenance, unit, sqft, maintenance_psf, href, note }`. A bad or unknown uuid gives
`prefill: null` and the ordinary card, never a 404: the link was the listing's promise, not the
member's to have got wrong. `ImproveCard.vue` shows a **fill / ignore banner** and writes nothing until
填入 is pressed; then `applyPrefill()`
([utils/road/projectPrefill.js](/resources/js/utils/road/projectPrefill.js)) fills **only empty fields**
(`''`, `null`, `undefined`) — a typed value is the member's judgement and a catalogue figure must never
replace it — stamps the source line into `evidence.market` only when that line is blank, and Save stays
explicit. `area_key` is filled only when the project's nearest growth area is on the A card's
short-list (the field is a select over that list, never free text).

**3 · Server-side completeness.** `Src\Journey\Support\Completeness` — the PHP twin of `math.js
completeness()`, including the D arithmetic Measure's band check needs (`borrowable()` / `mof()` /
`priceMax()`, the same `headroom × 200 ÷ MoF` rounded to the nearest RM 1,000). It is what lets the
repository downgrade a client flag (see *How it works*), and `tests/Unit/Journey/CompletenessTest.php`
feeds it the very payloads `math.test.js` feeds the JS, so the two cannot drift silently.
`Src\Journey\Support\Instalment` is the same idea for the loan: one PHP formula matching
`utils/wealthPlan/installment.js` (`monthly()` for PHP, `factor()` for the per-ringgit constant a SQL
`WHERE` multiplies a price by), pinned at RM 540,000 @ 4 % / 35 y = 2,390.98.

**4 · Control card → the Wealth Plan (plan vs actual).** When a Control card is saved on a cycle that
has a pinned `wealth_plan_id`, `saveCard()` mirrors the purchase into the plan's FIRST property row —
the one `WealthPlanSeed` created — inside the card's own transaction, under `lockForUpdate()` (the
canvas autosaves the whole blob on a debounce, so two writers into one JSON column must take turns), and
through `WealthPlanRepository::update()` so the plan's summary columns recompute.
`Src\Journey\Support\WealthPlanSync::mirror()` writes exactly three keys on `state.properties[0]`:

| key | value |
|---|---|
| `properties[0].status` | `planned` \| `signed` \| `handed_over` \| `rented` — the journey status as a word (`WealthPlanSync::STATUSES` carries the Chinese + colour) |
| `properties[0].actual` | `{ spa_at, vp_at, rented_at, actual_rent, bank_valuation }` — every key present, `null` = 我还不知道 |
| `properties[0].completionYear` | ONLY when `vp_at` is known: years from now to the real hand-over month, never earlier than `buyYear` |

`completionYear` is the one EXISTING key touched, because the projection engine already starts the rent
clock from it — an actual VP date is the fact the seed's "~3 years" was guessing at. Nothing else in the
blob changes. The write is skipped, never failed, when there is nothing honest to say: no pinned plan, a
plan that is no longer the member's or is soft-deleted, a plan with no property row, or a mirror that
changes nothing (`WealthPlanSync::equivalent()` compares recursively key-sorted, because MySQL
re-orders a JSON object's keys and a strict `===` called every identical re-save "changed"). The member
sees the result on the canvas — see
[Wealth Planning](/docs/modules_handbook/main/wealth-planning/readMe.md#plan-vs-actual-2026-08-28).

## The five-day training + the D card that opens the Wealth Plan (2026-09-02)

The founder's rebuild (spec: [`docs/plans/2026-09-02-road-world-class-elearning-spec.md`](/docs/plans/2026-09-02-road-world-class-elearning-spec.md),
review artifact fe609edf) changed what the road IS, not only how it reads:

- **D is the teaching version of the Wealth Planning canvas.** The D card carries every field
  Step 1 · 2 are seeded from — age, `career_mode` (who signs your paycheck: employer / own business /
  Resign Mode + `resign_age`), net income, the four commitment buckets (`car_loan · pers_loan · cc_min ·
  existing_housing`; `commitments` is derived as their total by `normalizePayload()` so old readers
  never see the two disagree), `residential_loans`, `existing_rent`, `living_expenses`,
  `liquid_savings`, `goal_type` (passive / equity / payoff), `target_income` (the card offers
  开销 × **1.5**), `target_age`, `whopay_url`. `Completeness` requires `career_mode`, `goal_type` and
  `living_expenses` on top of the old rule — a D card that cannot open the plan is not complete.
- **The plan opens ITSELF.** `LearningJourneyRepository::saveCard()` calls `ensureWealthPlan()` the
  first time the D card completes (inside the card's transaction; journey row locked so a racing
  handoff click cannot mint a second plan). `handoffWealth` is now 「打开我的 Wealth Plan」, and with
  `refresh=1` 「更新到 Wealth Plan」: `refreshWealthPlanFromDefine()` copies ONLY
  `WealthPlanSeed::ROAD_PROFILE_FIELDS` + the goal over — property rows and canvas-tuned assumptions
  are the member's. A plain click never re-seeds.
- **Step 4 is never empty — the DEMO unit (decision §11.3).** When M has priced nothing yet the seed adds
  one row `source: 'demo'`: the cohort's project when the trainee's event slot names one
  (`event_series.demo_catalog_project_uuid`, Manage → Events → slot form → *Demo project*; fallback
  `config('training.programs.five-day.demo_project_uuid')` = `TRAINING_DEMO_PROJECT_UUID`), else a
  synthetic dual-key priced at the member's own 60 % line × 0.9 with rent at the 7 % ROI floor
  (`WealthPlanSeed::demoUnit()` / `Src\Journey\Support\DemoUnit`). The I card replaces it
  (`WealthPlanSync::mirrorImprove()` — name, asking PSF × `sqft`, rent, `source → custom`; rate /
  years / growth / `actual` / `status` untouched).
- **The five-day program** (`config/training.php`): Day 1 起 + D · Day 2 M + A · Day 3 I · Day 4 C ·
  Day 5 T. **Reading the road is never gated; tools unlock per completed card**, and only for a lead
  with a `training_enrolments` row that has not graduated (everyone else, and every admin, is open):
  `wp.step.3` ← I (Day 3) · `area_guide` / `analyze` ← M (Day 2) · `analyze.projects` (the New
  Project listing) ← A (Day 3) · `landlord` ← C (Day 4) · `next_round` ← T (Day 5). One class decides
  — `Src\Journey\Support\TrainingAccess` (`verdict(?User, feature)` → `AccessVerdict` with
  `reason / day / required_card / card_href`; `summary()` for the hub strip; memo scoped to the
  REQUEST because a route caches its controller instance for the app's life) — and every surface
  renders the verdict it is handed: `WealthPlanningController@edit` (`training.locks.3`),
  `CoursesController::areaGuideState()` (`reason/day/cardHref`), `LockAnalyzeProperty::traineeGate()`
  (feature by path), `LandlordManagementController@index` → `Main/Portal/Training/Locked`, `Next.vue`
  (`next_round`). Enrol: `POST property/academy/road/training/enrol` (`RoadController@enrol`,
  idempotent, cohort = the lead's latest session registration's slot); graduate:
  `TrainingEnrolmentRepository::graduateIfComplete()` after every card save (all seven cards complete).
  Trail: `ActivityLog::TYPE_TRAINING_ENROLLED = 54` / `TYPE_TRAINING_GRADUATED = 55`; the team is told
  through Notifier events `training.enrolled` / `training.graduated`.
- **The New Project listing has a FOURTH sieve, ROI** (`?roi=1`; `config('road.quality.roi_floor') = 7`
  = math.js `ROI_FLOOR`): entry unit's rent × 12 ÷ price, judged on the same unit as the cashflow
  sieve; the card carries `road.roi { result, rent, price, roi_pct, floor }` and the page reads
  `road.roiFloor`. `DEFINE_CARD_HREF` is `…/define?beat=card` — Stage.vue resolves the alias to the
  station's card screen, so the server never has to know where D's card sits.
- **Presentation** — every station is typeform-style (one point per screen, `PointStepper` + a `viz`
  per point with a `state`, `Term` chips defined on first use, objectives / recap / key terms, the
  check BEFORE the card). Rules: [content-rules.md](content-rules.md); contracts:
  [`docs/plans/2026-09-02-road-build-contracts.md`](/docs/plans/2026-09-02-road-build-contracts.md).

## Locks — what a member can actually reach today

⚠️ **Two of the road's hand-offs currently land on a locked page, and both locks default ON.**

| Flag (`config/features.php`) | Env key | Default | What the road hits |
|---|---|---|---|
| `analyze_property_locked` | `ANALYZE_PROPERTY_LOCKED` | **true** | `App\Http\Middleware\LockAnalyzeProperty` matches `analyze-property` + `analyze-property/*`, so **I**'s 「打开 New Project 列表」 and every 带进 I 卡 round trip serve `AnalyzeProperty/Locked.vue` to a non-admin. **The Phase 2 strip is therefore invisible to members** — admins pass through and see it. |
| `area_guide_locked` | `AREA_GUIDE_LOCKED` | **true** | The guide is a TAB, which middleware cannot match, so the lock lives in `CoursesController::areaGuideState()`. **A**'s per-area links open the coming-soon panel (and the Mapbox token is withheld from it). |

**Turning either on is an env change, no deploy — but it must be `=0`, never `=false`.** `env()` in this
app is **CakePHP's** (`vendor/cakephp/core/functions.php`), which returns the raw string: `(bool) "false"`
is `true`, so `ANALYZE_PROPERTY_LOCKED=false` leaves the section locked and looks like a bug in the flag.
Write `ANALYZE_PROPERTY_LOCKED=0` / `AREA_GUIDE_LOCKED=0`, then `php artisan config:cache`.

Both flags are forced **off** in `phpunit.xml`, so the road's own suites (and Analyze's) exercise the
real pages; `AnalyzePropertyLockdownTest` turns the lock back on to prove it still bites.

## Runbook — `php artisan lms:recut-dmaic`

Course 4's chapters were cut by TOPIC; the road needs them cut by STAGE (spec v2 §4.1: the six property-
type lessons move A → M, the supply/demand groups I → A, the Bonus chapter is renamed
`第二、三轮的 D：没有了90% Quota`). Until it runs, the Analyze shelf shows type lessons and the Improve
shelf shows supply/demand — **expected**; `config/road.php` maps stages to chapter uuids and the recut
moves lessons, not the map.

- `php artisan lms:recut-dmaic` — **dry run by default** (`--dry-run` is accepted for readability): prints
  the plan (19 lesson moves + 1 rename), changes nothing, refuses to proceed if any uuid is missing.
- `php artisan lms:recut-dmaic --force` — applies everything inside ONE `DB::transaction`, after writing a
  snapshot of every touched row's `chapter_id` / `sort_order` / title to
  `storage/app/lms/recut-dmaic-{timestamp}.json`. Lessons are moved by uuid — **never deleted or
  recreated**, so `lesson_progress` and the signed lesson URLs survive.
- `php artisan lms:recut-dmaic --revert=latest` (or `--revert=<file under storage/app/lms>`) — restores
  the snapshot.
- **The founder runs it, once, against production** — never from a dev box against the live DB.
- The recut does not touch 起's shelf: `stages.mindset.chapter_uuids` is the 开始 chapter then 第1单元,
  both course 4's own opening, and neither moves.

## After merge (this box serves the working tree — see CLAUDE.local.md)

1. `php artisan migrate` — `2026_08_27_160000_create_learning_journeys_tables.php` (two tables). Until
   it runs every road page 500s ("table not found"). **Neither Phase 2 nor 起 added a migration**: stage
   0 fits the existing `unsignedInteger` column, and every Phase 2 figure is derived, not stored.
2. `php artisan config:cache` — `config/road.php` gained `stages.mindset`, `growth_areas`,
   `growth_area_radius_km` and `quality`. A stale config cache leaves the 起 shelf empty, the New
   Projects strip with no area chips, and `config('road.quality.*')` on its in-code fallbacks.
3. `php artisan route:cache` — the five `main.portal.road.*` routes 404 under a stale route cache, and
   the `{stage}` constraint that now admits `mindset` lives in the cached file.
4. `npm run build` via `/live-update` (maintenance mode: Vite empties `public/build` first).
5. `php artisan horizon:terminate` if queued code changed (nothing here queues).
6. No new `Permission` constants → no seeder run needed.

## Related files

**Backend**
- [src/Journey/LearningJourney.php](/src/Journey/LearningJourney.php) — the cycle: `STATUS_*` + `STATUSES`, `currentFor()`, `statusFromControlPayload()`.
- [src/Journey/JourneyCard.php](/src/Journey/JourneyCard.php) — the card: `STAGE_*` + `STAGES` (**seven**, 起 = 0) + `DMAIC_STAGES`, the taught vocabularies as constants (`PROPERTY_TYPES`, `ROUTES`, `DEMAND_DRIVERS`, `SUPPLY_RISKS`, `DEVELOPER_RESULTS`, `TAKEOFF_PATHS`), `PAYLOAD_FIELDS`, `normalizePayload()`, `hasAnyAnswer()`, `stageFromKey()`.
- [src/Journey/Repositories/LearningJourneyRepository.php](/src/Journey/Repositories/LearningJourneyRepository.php) — the ONLY writer: `start()` · `saveCard()` (completeness downgrade + the Wealth Plan mirror) · `attachWealthPlan()`. Facade: [src/Journey/Facades/LearningJourneyRepository.php](/src/Journey/Facades/LearningJourneyRepository.php).
- [src/Journey/Support/RoadPresenter.php](/src/Journey/Support/RoadPresenter.php) — the ONE prop shape (journey / cards / stages / staleness / previous / cycles / hub); `position()` and `UNDERIVED_STAGES`.
- [src/Journey/Support/Completeness.php](/src/Journey/Support/Completeness.php) — the PHP twin of `math.js completeness()` (+ `borrowable()` / `mof()` / `priceMax()` / `withinBand()` / `controlProgress()`).
- [src/Journey/Support/Instalment.php](/src/Journey/Support/Instalment.php) — `monthly()` / `factor()`, the PHP twin of `installment.js`.
- [src/Journey/Support/ProjectPrefill.php](/src/Journey/Support/ProjectPrefill.php) — a catalogue project → the Improve card's fields (entry plan, asking PSF, nearest growth area, the maintenance assumption).
- [src/Journey/Support/WealthPlanSeed.php](/src/Journey/Support/WealthPlanSeed.php) — D + M cards → a Wealth Planning canvas state; the demo unit (`demoUnit()`, `ownLineCeiling()`, `SOURCE_DEMO`, `ROAD_PROFILE_FIELDS`).
- [src/Journey/Support/WealthPlanSync.php](/src/Journey/Support/WealthPlanSync.php) — Control card → the road row's `status` / `actual` / `completionYear`; `mirrorImprove()` (the I card's unit replaces the demo row); `equivalent()` (the key-sorted no-op test).
- [src/Journey/Support/DemoUnit.php](/src/Journey/Support/DemoUnit.php) — the cohort's / the program's catalogue project as demo-row facts.
- [src/Journey/Support/TrainingAccess.php](/src/Journey/Support/TrainingAccess.php) + [AccessVerdict.php](/src/Journey/Support/AccessVerdict.php) — the five-day program's ONE gate; [src/Journey/TrainingEnrolment.php](/src/Journey/TrainingEnrolment.php) + [Repositories/TrainingEnrolmentRepository.php](/src/Journey/Repositories/TrainingEnrolmentRepository.php) (enrol / graduate / graduateIfComplete); [config/training.php](/config/training.php).
- [app/Http/Middleware/LockAnalyzeProperty.php](/app/Http/Middleware/LockAnalyzeProperty.php) — the temporary global lock AND the trainee gate (`traineeGate()`).
- [app/Http/Controllers/Main/Portal/RoadController.php](/app/Http/Controllers/Main/Portal/RoadController.php) — `stage` · `next` · `saveCard` · `startCycle` · `handoffWealth`; the lesson shelf from `config/road.php`; the `?project=` prefill.
- [app/Http/Requests/Main/Portal/Road/SaveCardRequest.php](/app/Http/Requests/Main/Portal/Road/SaveCardRequest.php) · [StartCycleRequest.php](/app/Http/Requests/Main/Portal/Road/StartCycleRequest.php).
- [config/road.php](/config/road.php) — course 4 uuid, the stage → chapter map (incl. `mindset`), the Bonus chapter, T's course, plus Phase 2's `growth_areas` / `growth_area_radius_km` / `quality`.
- [app/Console/Commands/RecutDmaicCourse.php](/app/Console/Commands/RecutDmaicCourse.php) — `lms:recut-dmaic` (runbook above).
- [src/Common/ActivityLog.php](/src/Common/ActivityLog.php) — `TYPE_JOURNEY_CARD_SAVED = 53` (Learning, coalesced).
- [app/Http/Controllers/Main/Portal/CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php) — the hub's `road` prop (`RoadPresenter::hub()`).
- [app/Http/Controllers/Main/Portal/AnalyzePropertyController.php](/app/Http/Controllers/Main/Portal/AnalyzePropertyController.php) — `newProjects()`'s `dNumbers` prop (the member's D card) and the MoF it hands the listing.
- [app/Actions/BuildNewProjectListing.php](/app/Actions/BuildNewProjectListing.php) — the three-step strip, the three sieves as shared SQL fragments, and the per-card verdicts.
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `road.cycles[]` + `road.training` on `show()` and `quick()` (the advisor's brief).
- [app/Http/Controllers/Manage/Portal/ActivityController.php](/app/Http/Controllers/Manage/Portal/ActivityController.php) `training()` + [src/Journey/Support/TrainingProgress.php](/src/Journey/Support/TrainingProgress.php) — Portal Engagement → Activity → 训练, the trainees list + funnel (see the Portal Engagement handbook).

**Frontend (Vue) — member portal**
- Pages: [Road/Stage.vue](/resources/js/Pages/Main/Portal/Road/Stage.vue) (the stepper shell) · [Road/Next.vue](/resources/js/Pages/Main/Portal/Road/Next.vue) (the terminal screen) · [Lms/Partials/Road/RoadOverview.vue](/resources/js/Pages/Main/Portal/Lms/Partials/Road/RoadOverview.vue) (the hub tab; mounted by [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue) as the first tab).
- Beats: [Partials/Beats/](/resources/js/Pages/Main/Portal/Road/Partials/Beats/) — `beats.js` (the nine-beat table + `cardHref()`) · `BeatRail` · `BeatIndex` · `BeatSection` · `QuestionBeat` · `ConceptBeat` · `AnswerBeat` · `MistakesBeat` · `RyanBeat` · `CasesBeat` · `CardBeat` · `DeeperBeat` (+ `Quiz`, `VerifyLinks`, `GlossaryChips`, `LessonsShelf`, `HandoffButtons`).
- Cards: [Partials/Cards/](/resources/js/Pages/Main/Portal/Road/Partials/Cards/) — `cardForm.js` (seed / clean / completeness / PUT) · `CardShell` · `Field` · `Chips` · `Readout` · `DefineCard` · `MeasureCard` · `AnalyzeCard` · `ImproveCard` (+ the prefill banner) · `ControlCard`.
- Mindset: [Partials/Mindset/](/resources/js/Pages/Main/Portal/Road/Partials/Mindset/) — `MindsetStepper` (the five-screen rail, WAI-ARIA tablist with roving tabindex) · `FoundationScreen` (screens 1–4; kept mounted across them so its photograph crossfades instead of the panel flashing) · `ToolsScreen` (the six tool cards + the promise CTA, which PUTs `{ acknowledged: true }` and then walks the member into D — 「从 D 开始」 is what the promise says, so leaving them on the screen would make the button a no-op with a toast) · [Mindset/Viz/](/resources/js/Pages/Main/Portal/Road/Partials/Mindset/Viz/) — `EsbiQuadrant` · `LeverageVsStocks` · `DmaicMap`, the three foundation diagrams.
- Takeoff: [Partials/Takeoff/](/resources/js/Pages/Main/Portal/Road/Partials/Takeoff/) — `TakeoffStepper` · `ScreenFrame` · `TakeoffQuiz` · `CountdownScreen` · `DeathValleyScreen` · `ThreeEnginesScreen` · `TwoDoorsScreen` · `OrderLawScreen` · `CourseMapScreen` · `ActionListScreen` (the T card).
- Viz: [Partials/Viz/](/resources/js/Pages/Main/Portal/Road/Partials/Viz/) — `palette.js` · `DsrGauge` · `LeverageBars` · `FunnelDiagram` · `PsfCompare` · `CashflowWaterfall` · `ConstructionTimeline` · `DsrTimeline` · `CycleWheel`; [Viz/Takeoff/](/resources/js/Pages/Main/Portal/Road/Partials/Viz/Takeoff/) — `BadgeTwoAOneP` · `CountdownViz` · `DeathValleyChart` · `EngineIgnition` · `TwoDoorsSim` · `EngineSimulator` · `PEnginePuzzle`.
- Helpers beside the page: [roadFormat.js](/resources/js/Pages/Main/Portal/Road/Partials/roadFormat.js) · [ryanCase.js](/resources/js/Pages/Main/Portal/Road/Partials/ryanCase.js).
- Shared strips: [Components/Road/PathMap.vue](/resources/js/Components/Road/PathMap.vue) · [SummaryBar.vue](/resources/js/Components/Road/SummaryBar.vue) · [StageHero.vue](/resources/js/Components/Road/StageHero.vue).
- Content + maths (pure ES modules): [utils/road/stages.js](/resources/js/utils/road/stages.js) (`STAGES`/`STAGE_KEYS` vs `ROAD_STAGES`/`ROAD_KEYS`, `MINDSET_STAGE`, `TAKEOFF_STAGE`, `areaGuideHref`) · [content.js](/resources/js/utils/road/content.js) · [math.js](/resources/js/utils/road/math.js) · [takeoffContent.js](/resources/js/utils/road/takeoffContent.js) · [takeoffMath.js](/resources/js/utils/road/takeoffMath.js) · [projectLinks.js](/resources/js/utils/road/projectLinks.js) (`improveHref`) · [projectPrefill.js](/resources/js/utils/road/projectPrefill.js) (`prefillPlan` / `applyPrefill` / `marketEvidence`).
- Touched by the road's hand-offs: [Components/AreaGuide/AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue) (`?area=` / `?country=` / `?region=` open that area on mount) · [Pages/Main/Portal/AiConversations/Index.vue](/resources/js/Pages/Main/Portal/AiConversations/Index.vue) + [Components/Conversation/Composer.vue](/resources/js/Components/Conversation/Composer.vue) (`?prompt=` seeds the composer via the additive `initialText` prop) · [Pages/Main/Site/NewProjects.vue](/resources/js/Pages/Main/Site/NewProjects.vue) + its [RoadFilterStrip.vue](/resources/js/Pages/Main/Site/Partials/RoadFilterStrip.vue) / [ProjectCard.vue](/resources/js/Pages/Main/Site/Partials/ProjectCard.vue) partials + [newProjects.js](/resources/js/Pages/Main/Site/newProjects.js) (the strip, the verdict badges and 带进 I 卡; the two partials were split out 2026-09-03 so [How to Use PropertyLab](/docs/modules_handbook/main/portal-guide/readMe.md) can mount the real thing in a lesson) · [utils/wealthPlan/propertyHelpers.js](/resources/js/utils/wealthPlan/propertyHelpers.js) (the `status` / `actual` the Control card mirrors).
- Images: `public/main/images/road/` — `{define,measure,analyze,improve,control,takeoff}.jpg` (stage heroes), `concept-*.jpg`, `foundation.jpg` + `foundation-{esbi,leverage,map}.jpg` (起's screens), `next.jpg`, `takeoff-doors.jpg` and the T widgets' backdrops; every `.jpg` has a `.webp` sibling.

**Frontend (Vue) — Manage**
- [Pages/Manage/Leads/Partials/Tabs/RoadTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/RoadTab.vue) — the advisor's brief: per cycle, the seven cards as raw answers + ƒ-marked figures recomputed with the same `math.js`. The FIRST row is 起, two lines and no more (its card is one boolean): whether this person signed 「先算，再看房」 and when — the difference between a lead who wandered into D and one who started at the beginning.
- Registration: [composables/useLeadTabs.js](/resources/js/composables/useLeadTabs.js) (`portalTabs` → `road`, hidden when the lead has no cycle) · [Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) · [Components/LeadDetailModal.vue](/resources/js/Components/LeadDetailModal.vue) (`#tab-road` in both, so `useLeadTabs.test.js`'s parity rule holds).

**Migrations**
- [database/migrations/2026_08_27_160000_create_learning_journeys_tables.php](/database/migrations/2026_08_27_160000_create_learning_journeys_tables.php) — `learning_journeys` + `learning_journey_cards` (literal status default, UNIQUE(`lead_id`,`cycle_no`) and UNIQUE(`journey_id`,`stage`), no FKs).
- [database/migrations/2026_09_02_180000_create_training_enrolments_table.php](/database/migrations/2026_09_02_180000_create_training_enrolments_table.php) — `training_enrolments` (lead × program, `started_at` / `graduated_at`, optional cohort `event_series_id`).
- [database/migrations/2026_09_02_190000_add_demo_catalog_project_to_event_series.php](/database/migrations/2026_09_02_190000_add_demo_catalog_project_to_event_series.php) — `event_series.demo_catalog_project_uuid`.

**Seeders** — none (the first card a member saves opens cycle 1).

**Routes**
- [routes/main.php](/routes/main.php) — the six `main.portal.road.*` routes (+ `training.enrol`), declared BEFORE `property/academy/{course}` (a literal `road` segment must never be read as a course uuid); `{stage}` constrained to `mindset|define|measure|analyze|improve|control|takeoff` on the GET and the PUT alike.

**Tests**
- Feature: [tests/Feature/Main/Portal/Road/](/tests/Feature/Main/Portal/Road/) — `RoadTestCase` (fixture, the AreaGuide pattern) · `RoadStageTest` · `RoadCardTest` · `RoadCycleTest` · `RoadHandoffTest` · `TakeoffCardTest` · **`MindsetCardTest`** (the promise completes the card and opens cycle 1; a `1` stores as a real `true`; an unpressed promise stays hollow whatever the client flags; positions run 1–7 while `DMAIC_STAGES` stays five; 起 is never stale and never makes a later card stale; 继续 sends a fresh member to 起 and a member on the road past it; the shelf is 开始 then 第1单元; the advisor's brief carries `cards.mindset` additively) · **`RoadCompletenessTest`** (the server-side downgrade) · **`RoadWealthSyncTest`** (Control → the plan) · **`RoadPrefillTest`** (`?project=`) · `RoadMergeTest` (an account merge keeps the WINNING cycle live) · `RecutDmaicCommandTest` (dry run changes nothing) · **`TrainingTest`** (enrol is one idempotent POST; a non-trainee sees no locks; a trainee is held until the card that teaches each tool; the seventh card graduates; an admin is never locked).
- Unit: [tests/Unit/Journey/](/tests/Unit/Journey/) — `LearningJourneyTest` (`statusFromControlPayload`) · `JourneyCardTest` · `WealthPlanSeedTest` · **`CompletenessTest`** (fed math.test.js's own payloads) · **`InstalmentTest`** · **`ProjectPrefillTest`** · **`WealthPlanSyncTest`** · **`WealthPlanSyncImproveTest`** (the I card replaces the demo row).
- Feature (Analyze): [tests/Feature/Main/Portal/Analyze/RoadListingStripTest.php](/tests/Feature/Main/Portal/Analyze/RoadListingStripTest.php) (the strip narrows, the verdicts agree with the filter, public chrome untouched) · [GrowthAreasConfigTest.php](/tests/Feature/Main/Portal/Analyze/GrowthAreasConfigTest.php) (reads `malaysia.js` and fails the moment the pins drift from `config('road.growth_areas')`).
- Vitest: [utils/road/math.test.js](/resources/js/utils/road/math.test.js) · [stages.test.js](/resources/js/utils/road/stages.test.js) · [takeoffMath.test.js](/resources/js/utils/road/takeoffMath.test.js) · [projectLinks.test.js](/resources/js/utils/road/projectLinks.test.js) · [projectPrefill.test.js](/resources/js/utils/road/projectPrefill.test.js) · [Partials/roadPartials.test.js](/resources/js/Pages/Main/Portal/Road/Partials/roadPartials.test.js) · [Partials/Cards/ImproveCard.test.js](/resources/js/Pages/Main/Portal/Road/Partials/Cards/ImproveCard.test.js) · [Components/Road/road.test.js](/resources/js/Components/Road/road.test.js) (PathMap's segments, SummaryBar's rules, RoadTab, Next.vue's three states + the A2 line) · [Lms/Partials/Road/RoadOverview.test.js](/resources/js/Pages/Main/Portal/Lms/Partials/Road/RoadOverview.test.js) · [Road/Stage.test.js](/resources/js/Pages/Main/Portal/Road/Stage.test.js) (the stepper: the beat table, `?beat=`, the card beat, TakeoffStepper).

**See also:** [Courses / LMS](/docs/modules_handbook/manage/lms/readMe.md) — the Learning Hub this tab lives in and the course the shelves list ·
[Wealth Planning](/docs/modules_handbook/main/wealth-planning/readMe.md) — where the D card is handed off, and where the C card's facts come back as plan-vs-actual ·
[Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) — where A is learned (`?area=` deep link) ·
[Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md) — M · A · I in practice, and the three-step strip Phase 2 built ·
[Landlord Management](/docs/modules_handbook/main/landlord-management/readMe.md) — the Control stage's tool ·
[Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) — the `catalog_projects` rows the strip filters and the I card prefills from ·
[Grant Application](/docs/modules_handbook/main/grant-application/readMe.md) — T's OPM #2 ·
[AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) — "Ask AI Coach about this stage" (`?prompt=`) ·
[Activity trail](/docs/modules_handbook/shared/activity-log/readMe.md) — every card save is logged there.
