# 案例复盘 — Case Debrief (Main · User Portal)

**Portal:** Main · **Routes:** none of its own — it is a TAB of the Learning Hub
(`GET /property/academy`, `Main\Portal\CoursesController@index`), addressed as
`?tab=cases`, and a frame inside it as `?tab=cases&case=<key>&s=<screen>&p=<point>` ·
**Nav:** "Learning Hub" → **案例复盘** tab, third in the strip (after 「How to Use PropertyLab」
and 「DMAIC 之路」) ·
**Gated by:** the portal group (`['auth','main','contact.verified']`) — nothing inside is a gate ·
**Server props:** **none**. The tab is content + behaviour only ·
**Mobile app:** `GET member-api/v1/learning/content/cases` — `utils/cases/` exported to JSON by
`scripts/export-learning-content.mjs` (each diagram as `kind:state` + the alt text its component
writes), and each diagram served as a PNG (`viz.image_url`) painted by
`scripts/render-case-diagrams.mjs`. **Edit a case → re-run the export and commit
`resources/learning-content/`**; CI fails on a stale export. **Edit a diagram, or name a new frame →
re-paint the PNGs and commit them too.** CI cannot catch a stale PNG (it has no Chrome); `node
scripts/render-case-diagrams.mjs --check` can. See
[LMS › mobile-api.md › Case diagrams as PNGs](/docs/modules_handbook/manage/lms/mobile-api.md).

> ✍️ **Before touching any copy here, read
> [content-rules.md](/docs/modules_handbook/main/dmaic-road/content-rules.md)** — the same
> first-time-reader rules the road obeys, because it is the same reader.
> `resources/js/utils/cases/cases.test.js` machine-checks five of them.

> **Two languages (2026-09-25).** Every sentence here is a 中文 / English pair (`bi()` / `tx()`), shown in
> the member's choice from the Learning Hub's toggle — see
> [shared/learning-languages](/docs/modules_handbook/shared/learning-languages/readMe.md). Never add Chinese without its English.

## What it does

Takes ONE finished property purchase apart, screen by screen, until the decision that produced
the outcome is visible — then hands the reader a checklist to use on the property they are
looking at tonight.

**The road and a debrief run in opposite directions, and that is why this is a tab of its own.**
DMAIC 之路 walks a purchase FORWARD: what to do next, a card at the end of every stop. A debrief
starts from a result that already exists — the price paid, the rent collected, the six years —
and works BACKWARD to the decision. A member who has just read what to do next is exactly the
person who should see what it costs to skip a step.

It is also NOT a station's 「真实案例」 beat: that is two paragraphs beside the method it
illustrates. A debrief is seven screens with its own arithmetic, its own diagrams and an ending
that asks the reader for answers.

### Case 01 · 布城边上那一排 landed

The founder's own purchase, told in the first person and still in his name. Bought at
**RM 580,000** beside Putrajaya (Dengkil) on beliefs none of which was ever checked (the
「有地 = 稀缺 = 升值，所以贴钱正常」 chain, and a High Speed Rail station planned 2–3 km away),
held **6 years** at **−RM 1,200 a month**, worth about what it cost.

**Source (2026-09-06).** The founder's two-hour Zoom telling of it on 2026-09-04 —
「买 Landed 一定会升值？一间房教我的 3 个代价」— transcript plus sixteen slides (rev2), kept as the
upload `/file/e2125fa5-aba2-40de-ba98-8633ebd391fe` (`GMT20260904-120048_Recording.transcript.zip`:
the Zoom `.vtt` and the `.pptx`). The case was rewritten from it: every figure is one he stated there or is derived
from those. What the telling added to the first draft: **why that plot at all** (he wanted a
RM 1M+ freehold township in Putrajaya/Cyberjaya, could not borrow it, passed Cyber South for
being leasehold, drove 3–4 minutes further for a freehold row — 「自我安慰」), the top-up being
**planned** rather than discovered, the **two months** a tenant change costs, **OST** (Own Stay
Test) and **BMS** (Bank · McDonald's · Starbucks) by name, the HSR's actual end (January 2021,
> RM 320m compensation; revival rumours since 2023, no decision), **Cyber South's transaction
report** as the township-next-door comparison (launched ~RM 520k, still RM 530–550k in 2025, but
letting at RM 1,400–1,600), the **brother's Seremban 2 → KLCC commute** (1.5–2.5 h, now by
motorbike — a township that fails OST), and the **35-year hold horizon** that closes it.

Eight screens: 当时的判断 · 我买到什么 · 每月的数字 · 等不到的车 · 六年后 · 三条教训 ·
再等 35 年 · 换成你 — 41 points, about 15 minutes.

Three rules come out of it, and the last screen turns them into six questions (OST is the
sixth, under rule 1):

1. **买今天成熟的地方** — 成熟看今天，不看承诺 — and you would live there yourself.
2. **看不见的规划不算数** — 能开车去看得到的才算; 「以后会」 is the most expensive phrase.
3. **landed 要买整体城镇规划** — 买整座城，不买一排 (Setia Alam is the counter-example; the
   brother's Seremban 2 is the township that still fails OST).

**Every figure is derived, never typed.** `DEAL` holds the numbers the owner actually stated
(buy price, instalment, rent, years, plus the few the talk added — the township he wanted, the
neighbour's launch/2025/rent figures, the tenant-change months, the 35-year horizon and its three
growth rates) and `NUMBERS` computes the rest — the RM 86,400 of six years' top-ups, the 2.7%
gross yield against a 4% loan, the 1.4%-a-year growth, the −RM 36,400 in cash, the RM 666,400
break-even sale price, the RM 5,000 a tenant change costs, and the 35-year horizon (RM 504,000 of
top-ups against ≈ RM 822k / 1,632k / 3,199k at 1 / 3 / 5% a year, rounded to the thousand). The
prose, the diagrams and the catalogue card all read the same constants, so they cannot drift;
`cases.test.js` re-does the arithmetic.

**The catalogue card's picture is the case's `cover`** (`image`, `alt`, `place`, `headline[]`,
`note`), defined on the case module and not in `CasesTab.vue` (2026-10-01). The member app's case
list draws the same cover, so a new case with a picture only needs the field. The card shows the
image's right half (`w-[200%] object-right`), and the app does the same.

**Two people, two cards.** Wai Kit's card is on the first point; the brother's (`BROTHER_CARD`)
is on the ONE point that spends his numbers (第 6 屏 · 哥哥的芙蓉第二城), per content-rules.md 3 —
`cases.test.js` checks the card sits on his first mention.

⚠️ **RPGT is deliberately NOT charged against this case.** A citizen selling in year 6 pays 0%,
and the screen says so instead of implying a tax that would not be owed — the honest version of
the loss is what makes the rest believable (content-rules.md 11).

## How it works

**No server, no table, no route.** The tab renders from data files and keeps the reader's place
in `localStorage`. That is a deliberate stopping point, not an oversight: the road's copy lives in
JS for the same reason (it is versioned, reviewed and testable), and a Manage authoring surface is
worth building only when the number of cases makes editing a file the bottleneck. When
member-level "who read what" is wanted, it lands with the portal guide's — see *Adding a case*
below for the two functions that would change.

**Visual entrance.** The catalogue introduces each narrator and retains real case metadata,
read/resume state and the existing reader button. The Putrajaya case uses existing property
artwork explicitly labelled as an illustration, not a photograph of the actual property.
The checklist count comes from the case's screen data rather than a fixed numeral.

`PutrajayaPreview.vue` adds an optional decision before the full reading. Either answer reveals
monthly rent and repayment bars plus a native year slider. It imports `DEAL` and `NUMBERS`
from the case file; cash top-ups are computed from the same monthly gap, with the case duration
as the slider maximum. The calculation is labelled as cash supplied, excluding other costs and
not total investment profit/loss. Answering or moving the slider does not write reading progress
or mark the case complete. This is a catalogue interaction, not a new registered diagram frame
or mobile-content export. `PutrajayaPreview.test.js` covers reveal behavior, both answers and
the source-derived total at different durations.

**Reading grammar is the road's, on purpose.** A case's point is the same object a station's is —
`{ key, title, line, calc?, viz?, terms?, character? }` — so `Components/Road/PointStepper.vue`,
`CalcSteps.vue`, `CharacterCard.vue`, `Term.vue` and `VizHost.vue` render it with nothing new to
learn. A member who has walked a station already knows how this moves.

- **`Term.vue` gained one additive prop, `entry`** (2026-09-04). A case passes `caseTerm(k)`,
  which reads `utils/cases/terms.js` first and the road's registry second. The case's own words
  **cannot** live in `utils/road/terms.js`: that file's test walks the ROAD and fails on any entry
  whose `definedAt` is not a unit on it — correctly, and a case is not on the road. Words the road
  already defines (`rpgt`, `net-equity`, `psf`) are reused through the fallback, so there is still
  one definition per word portal-wide.
- **The viz registry is now portal-wide, not the road's.**
  `Components/Road/vizRegistry.js` globs the cases' `Viz/` folder alongside the road's two. ONE
  registry, because one component (`VizHost`) resolves both — a second lookup table would let the
  same kebab-case kind mean two different drawings depending on which page mounted it. **Kinds are
  unique portal-wide.**
- **Addressing.** `?case=&s=&p=` is written with `history.replaceState`, the same mechanism
  `ShowTabs` uses for `?tab=` and for the same reason: a refresh and a shared link must land on the
  frame the reader was on. A point is not a history entry — Back leaves the tab.
- **Resume.** Opening a half-read case returns to where the reader stopped. A debrief is one
  argument; restarting it at screen 1 is how a reader abandons it the second time. A case they have
  finished opens at the beginning again (「再读一次」).
- **The five questions are answered in the component and nowhere else** — no server, no
  `localStorage`. They are about a property the portal knows nothing about, and a stale answer to
  「这个地方今天有人潮吗」 restored a month later would be worse than none. Three answers, never
  two: 「不确定」 is the state the case is ABOUT (the owner's own four 「不确定」 cost him six
  years), so a yes/no control would hide exactly what it exists to surface. The verdict is a
  SENTENCE that names the rules the answers broke — never a score, which would invite comparing
  two properties on a number this cannot support.

**The last screen hands off to tools that exist**: Area Guide (`?tab=area-guide`), Analyze
Property (`/analyze-property/new-projects`), Wealth Planning (`/wealth-planning`) and the road's
M station. `cases.test.js` asserts the hrefs stay inside those prefixes.

## Adding a case

1. Write `resources/js/utils/cases/<key>.js` — the shape is `putrajayaLanded.js`: derive every
   figure from the few the owner stated, one `screens[]` of `points[]`, `rules[]`, a `checklist`
   whose every question names one of those rules, `objectives` ×3 and `recap` ×3.
2. Add it to `CASES` in `resources/js/utils/cases/index.js`. Numbering (`no`) is written into the
   case, not derived — a case keeps its number when another is inserted before it.
3. Diagrams go in `Pages/Main/Portal/Lms/Partials/Cases/Viz/`. The registry globs the folder, so
   nothing registers them. A **cumulative** drawing (one that declares `const ORDER` and asks
   `reached()`) is checked by rule 18's test: the case may never walk it backwards.
4. New words go in `utils/cases/terms.js` — not the road's.
5. Run `npx vitest run resources/js/utils/cases resources/js/Pages/Main/Portal/Lms/Partials/Cases`.
   Then **read the case cold, as a member**: whether a paragraph teaches, and whether the Chinese
   reads as Chinese, is the half no test sees.
6. For the member app: `node scripts/export-learning-content.mjs`, then
   `node scripts/render-case-diagrams.mjs` (needs Chrome and a `public/build` that includes the new
   diagram's classes — `npx vite build` outside the live tree). **Look at every new PNG**, then commit
   `resources/learning-content/` and `public/learning-content/cases/`. A frame without a PNG still
   works in the app: it prints the alt text.

*(If a case ever needs server-side progress, it is `CasesTab.vue`'s `load()` / `save()` and
nothing else — they are the only two places that touch storage.)*

## Related files

**Frontend — content (pure data, no Vue)**
- `resources/js/utils/cases/index.js` — the registry (`CASES`), the reading walk (`casePoints`),
  `pointText`, `pointCount`.
- `resources/js/utils/cases/putrajayaLanded.js` — case 01: `DEAL`, `NUMBERS`, `WAI_KIT_CARD`,
  seven screens, `RULES`, `CHECKLIST`, `TOOLS`.
- `resources/js/utils/cases/terms.js` — the case term registry, `caseTerm()` (road fallback),
  `caseMentions()`.

**Frontend — components**
- `resources/js/Pages/Main/Portal/Lms/Partials/Cases/PutrajayaPreview.vue` — optional decision and cash-top-up illustration on the catalogue.
- `resources/js/Pages/Main/Portal/Lms/Partials/Cases/CasesTab.vue` — catalogue, local progress,
  the `?case=&s=&p=` URL.
- `…/Cases/CaseReader.vue` — the screen rail, the stepper, and everything in its `#below` slot
  (objectives · character card · term chips · checklist · tools · recap).
- `…/Cases/CaseChecklist.vue` — the five questions and the verdict.
- `…/Cases/Viz/` — `CaseSiteMap` · `AssumptionCards` · `PocketLand` · `MonthlyGap` ·
  `DemandChain` · `CaseScoreboard` · `CaseRules`. (`demand-driver-icons` is the road's, reused on
  screen 4 — the same five demand sources, so the case and the method name them identically.)

**Frontend — shared, touched by this module**
- `resources/js/Pages/Main/Portal/Lms/Index.vue` — the tab entry and its slot.
- `resources/js/Components/Road/vizRegistry.js` — globs the cases' `Viz/` folder.
- `resources/js/Components/Road/Term.vue` — the additive `entry` prop.

**Tests**
- `resources/js/utils/cases/cases.test.js` — shape, rule 1 (first use), rule 16 (spelled out),
  rule 17 (no chained arithmetic), rule 18 (the picture never runs ahead), rules 14 · 15 (neither
  word appears in any case source), and the arithmetic of case 01.
- `resources/js/Pages/Main/Portal/Lms/Partials/Cases/CasesTab.test.js` — resume, walking past a
  screen boundary, the URL, and the three verdicts.
- `…/Cases/Viz/casesViz.test.js` — every state the case names renders, with an aria-label and a
  caption, and the money diagrams quote the case's constants.

**Mobile app (export + serving)**
- `scripts/export-learning-content.mjs` — the `cases` set; `renderViz()` / `caseDiagramFrames()`
  are shared with the PNG painter.
- `scripts/render-case-diagrams.mjs` — paints `public/learning-content/cases/<kind>--<state>.png`
  and the manifest `resources/learning-content/case-diagrams.json`.
- `src/Lms/Services/CaseDiagrams.php` + `app/Http/Transformers/v1/MobileLearningContentTransformer.php`
  — attach `image_url` / `image_width` / `image_height` to each point's `viz`, and fold them into
  the served hash (the ETag).

**Backend (web)** — none. **Migrations / Seeders / Routes** — none.

## Related modules

- [DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md) — the method this case is the
  reverse of, and the owner of the reading grammar (`PointStepper`, `VizHost`, `Term`) and the
  [content rules](/docs/modules_handbook/main/dmaic-road/content-rules.md).
- [How to Use PropertyLab](/docs/modules_handbook/main/portal-guide/readMe.md) — the other
  Learning Hub tab built on the same stepper, and the model for the `localStorage` progress this
  one copies.
- [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) — where screen 7 sends the reader
  to answer 「这个地方今天有没有人潮」.
