---
url: https://docs.nemo12.com/architecture/sdd-038/exercises.md
description: >-
  Năm bậc bài tập mỗi bài một URL, ba trạng thái bài, bộ lọc bậc, mở khoá tuần
  tự bài tập, dấu trạng thái trên thẻ
---

# SDD-038 · Bài tập năm bậc và trạng thái

> Một phần của [SDD-038 Cổng IELTS](index.md). Phần đầu là đặc tả đang áp dụng, mỗi mục giữ số § cũ; diễn biến và lý do các vòng quyết định nằm ở mục **Lịch sử quyết định** cuối trang.

## 5i. Năm bậc bài tập, mỗi bài một URL (SRC-850, 2026-09-19)

### Vì sao cần một tầng nữa, chứ không chỉ thêm cột `level` vào câu hỏi

Trước bản này (§5f), một năng lực là MỘT thang dài: mở `/ielts/skills/reading/main-idea` ra là đi từ câu đầu tới câu cuối, không có chỗ dừng và không có gì để gửi cho nhau. Đặt `level` lên từng câu vẫn không giải được hai việc chỉ đạo vừa nêu:

* **"Mỗi bài tập đều có URL riêng"** - URL phải trỏ vào một thứ CÓ RANH GIỚI. Một câu hỏi thì quá nhỏ (URL cho từng câu là hàng nghìn URL không ai gửi cho ai), còn cả một bậc thì quá to.
* **"Learners có thể filter để chọn ra bài tập"** - lọc chỉ có nghĩa khi có nhiều VẬT THỂ để lọc. Lọc ra vài trăm câu rời thì learner vẫn đứng trước cùng một danh sách không biết bắt đầu từ đâu.

Nên `ielts_micro_exercises` đứng giữa năng lực và câu hỏi (migration 0250). Một bài tập là một buổi làm việc trọn vẹn: 5-10 đoạn, làm hết trong chừng mười phút, có điểm kết.

### Thành phần từng bậc nằm trong dữ liệu, không nằm trong đầu người soạn

| Bậc | Thành phần | Ghi chú |
| --- | --- | --- |
| 1 | 5 đoạn, đều tiếng Việt | Thao tác là việc của tư duy, không phải của vốn từ |
| 2 | 5 đoạn Việt + 5 đoạn Anh, bản Việt DỊCH TỪ bản Anh | Nội dung đã hiểu rồi, phần mới duy nhất là chữ |
| 3 | 10 đoạn ngắn, đều tiếng Anh | Không còn bản dịch đi kèm |
| 4 | Như bậc 2, đoạn dài hơn | Ý trải qua nhiều câu |
| 5 | Gần như đề thi thật | Hỏi theo cách đề thật hỏi (Matching Headings...) |

Bậc 2 và 4 là chỗ DUY NHẤT trong cả hệ có quan hệ giữa hai câu hỏi. Quan hệ ấy nằm ở cột `pair`, không nằm trong quy ước "seq n và seq n+5 thì là một cặp": quy ước ấy đúng cho tới bài đầu tiên có sáu đoạn Việt, và lúc sai thì nó sai im lặng - trang ghép nhầm hai đoạn không liên quan rồi nói với learner rằng đây là bản dịch của nhau. Đường đọc xếp theo `COALESCE(pair, seq)` rồi mới tới ngôn ngữ, nên bản Việt luôn đứng trước bản Anh của cùng cặp dù trang có xáo thứ tự.

### `level` và `stage` là hai khái niệm, không phải một

§5f đã có `stage` 1-4 với ranh giới chặng 1-2 tiếng Việt, chặng 3-4 tiếng Anh. Năm bậc KHÔNG thay thế nó: `level` nói **bài tập này khó tới đâu**, `stage` nói **câu này bằng thứ tiếng nào và ở độ dài nào**. Lấy `stage = level` sẽ sinh ra câu tiếng Anh mang chặng 2 ở bậc 2 song ngữ - phá đúng ranh giới §5f dựng lên, và phá im lặng vì trên màn hình không có gì khác thường. Ánh xạ nằm ở `stageOf()` trong `scripts/micro-seed.mjs`.

### Tỉ lệ Việt/Anh đổi, và đó là do chính quy cách này

§5f đặt luật "chặng tiếng Việt là chặng dài nhất" (chỉ đạo 2026-09-18: *"luyện tập tiếp rất nhiều kỹ thuật trên tài liệu tiếng Việt"*). Năm bậc cộng lại ra 15 câu Việt và 25 câu Anh mỗi năng lực, nên luật ấy không còn đúng được. Tiếng Việt KHÔNG bị cắt - nó tăng từ 10 lên 15; tiếng Anh mới là phần được thêm vào. Cổng kiểm tương ứng vì thế thu hẹp về đúng phần thang cũ thay vì bị nới ra hoặc bị xoá.

### Nội dung soạn ở `content/`, không soạn thẳng vào migration

Lý thuyết nằm trong D1 vì nó cần sửa được từ màn admin (§5e). Bài tập thì ngược lại: một bậc 2 là năm đoạn Việt cộng đúng năm đoạn ấy bằng tiếng Anh, và quan hệ dịch giữa chúng phải đọc được bằng mắt lúc soạn - trong một màn admin từng ô một, không ai kiểm được rằng đoạn Việt số 3 thật sự là bản dịch của đoạn Anh số 3.

Nên nội dung sống ở `content/ielts-micro/<skill>/<code>.json`, `scripts/micro-seed.mjs` sinh migration, và `scripts/check-micro-content.mjs` gác khuôn: số cặp, ngôn ngữ theo bậc, độ dài đoạn, đáp án trỏ vào lựa chọn có thật. Ngưỡng độ dài lấy từ dải THẬT của bộ nội dung mẫu chứ không bốc số tròn, và giữa bậc 2 và bậc 4 cố ý để một khoảng hở - hai bậc chỉ khác nhau ở độ dài, nên hai dải chạm nhau là hai bậc chạm nhau.

### Nội dung đã soạn (cập nhật 19.09.2026)

Toàn bộ 10 năng lực của phần ĐỌC đã có đủ nội dung năm bậc: `main-idea`, `detail`, `evidence`,
`inference`, `vocabulary`, `reference`, `purpose`, `tone`, `organization`, `summary`. Mỗi năng lực
10 bài tập và 80 câu, cộng lại **100 bài tập và 800 câu**, nạp bằng `migrations/0252_micro_content.sql`.

Cộng với 506 câu của thang bốn chặng (0234-0246) thì bảng `ielts_micro_skill_items` có 1.306 câu.
Con số ấy được gác bằng một phép đếm tay trong `routes.test.ts`: một migration nạp thiếu hoặc nạp
hai lần sẽ lộ ra ở đó, và mỗi lần soạn thêm nội dung thì con số phải được sửa - đó là chỗ người
review nhìn thấy đã thêm bao nhiêu.

Ba ràng buộc được kiểm bằng SQL sau khi nạp, ngoài cổng `check-micro-content` chạy trên JSON:

* không câu nào có `stage` lệch với `lang` (ranh giới của migration 0238 còn nguyên);
* mọi số `pair` đều ứng với đúng hai câu, một Việt một Anh;
* mọi `answer` đều trỏ vào một lựa chọn có thật.

Nghe, Nói và Viết (25 năng lực còn lại) chưa soạn.

## 42. Ba trạng thái của một bài: chưa làm · đang dở · đã xong (SRC-919, 20.09.2026)

Vế **đã xong** đã có từ trước: `GET /v1/learners/{id}/ielts-micro-attempts` đếm các dòng
`action='practice'` trong `learning_events` theo tiền tố `exercise:<skill>.<code>.l`.

Vế **đang dở** thì không đọc được từ sổ ấy, và đó là hệ quả của một quyết định cũ còn đúng: một
bài bỏ giữa chừng **cố ý không ghi dòng nào**, vì ghi lúc mở trang thì mở ra rồi đóng lại cũng
thành "đã học" và chuỗi ngày không bỏ buổi (SRC-801) mất hết nghĩa.

Nên trạng thái dở có chỗ riêng: bảng `ielts_micro_progress` (migration 0267), một dòng cho mỗi
cặp (learner, bài), giữ `answered / total` **và** `picks` - các lựa chọn đã chấm.

**Vì sao giữ cả `picks`, không chỉ một con số.** Một thẻ báo "In progress · 3/5" mà bấm vào lại
phải làm từ câu 1 thì cái nhãn ấy chỉ tố cáo learner chứ không giúp gì. Có `picks` thì mở lại bài
là về đúng câu chưa chấm đầu tiên, các câu cũ còn nguyên lời giải đã đọc.

**Làm xong thì XOÁ dòng, không đánh dấu `done=1`.** Trạng thái xong đã có một nguồn sự thật khác;
một cột thứ hai nói cùng điều ấy là hai bản sao sẽ có ngày lệch nhau. Hệ quả phụ dễ chịu: bảng
luôn nhỏ bằng số bài đang dở.

**Ghi sau MỖI câu được chấm**, không đợi lúc rời trang: learner rời bài bằng cách đóng tab hoặc
tắt máy nhiều hơn là bằng một nút Thoát, và `beforeunload` vừa không chạy trên di động vừa không
kịp gửi.

**Ba trạng thái, ba hình** trên lưới chọn bài, xếp theo mức đòi sự chú ý: đang dở (viền accent
đặc) nổi hơn đã xong (nền accent nhạt), vì bài xong rồi thì việc của nó là lùi lại. Bài vừa dở
vừa từng xong thì hiện cả hai nhãn - chúng nói hai điều khác nhau.

Một bẫy đã gài cổng: **chưa dựng lại xong thì chưa được lưu**. Thiếu cờ `resumed` thì lượt lưu
đầu tiên ghi đè bản trên máy chủ bằng một bài trống, đúng vào lúc learner vừa mở lại bài đang dở.

## 44. Bộ lọc bậc gọn lại, và mô tả chỉ hiện khi được chọn (SRC-921, 20.09.2026)

### 44a. Năm thẻ hai dòng, mô tả xuống dưới

SRC-850 đặt mô tả **vào trong** thẻ với lý do: learner đọc mô tả để CHỌN bậc, nên mô tả phải có mặt trước lúc chọn. Lý do ấy đúng, nhưng cái giá của nó thì chỉ nhìn thấy khi trang đã đủ nội dung: năm đoạn văn ba dòng xếp thành lưới 3 cột chiếm trọn màn hình đầu, và learner phải cuộn qua toàn bộ phần giải thích mới thấy được bài tập đầu tiên. Thứ learner tới trang này để làm là **làm bài**, không phải đọc về năm bậc.

Bản này giữ cả hai: thẻ còn hai dòng (`Level N` + thành phần, ví dụ "5 đoạn Việt + 5 đoạn Anh") nên năm thẻ đứng vừa một hàng và đọc được trong một cái nhìn; đoạn `why` chuyển xuống **dưới** hàng thẻ và chỉ in cho bậc đang chọn. Dòng `composition` ở lại vì nó chính là thứ phân biệt bậc 2 với bậc 4 khi chưa bấm vào đâu cả.

Mô tả đứng **ngoài** thẻ chứ không nở ra bên trong thẻ: nở trong thẻ thì thẻ đang chọn cao gấp ba bốn thẻ kia và cả hàng lệch nhau mỗi lần learner đổi bậc.

Lưới là `grid-cols-2 sm:grid-cols-3 lg:grid-cols-5`. "Một hàng" là hình dạng trên màn rộng; ép năm cột xuống màn điện thoại thì mỗi thẻ rộng chừng 70px và nhãn gãy chữ.

### 44b. Hàng tag dạng câu hỏi rời khỏi trang năng lực

Hàng tag ấy là phần giao diện của SRC-895 §35 trên trang năng lực. Gỡ nó đi **không** gỡ mô hình hai chiều: `/ielts/tasks/reading/<dạng>` vẫn là lối vào đầy đủ của chiều dạng câu hỏi, thẻ bài ở trang năng lực vẫn mang nhãn dạng (`L2 · Matching Headings`), và kho bài vẫn là một kho hai nhãn. Thứ mất đi là một bộ lọc thứ hai đứng chen giữa bộ lọc bậc và kết quả, trên một trang mà việc chính là chọn bậc rồi bấm vào bài.

Lọc theo `?task=<mã>` trên URL **vẫn chạy** dù không còn nút bấm nào đặt nó: một link đã lưu hoặc một đường dẫn sinh ra từ nơi khác vẫn cắt đúng kho như trước. Giữ nhánh lọc ấy rẻ hơn hẳn việc bỏ rồi dựng lại khi chiều dạng câu hỏi cần quay lại trang này.

### 44c. Một component, bốn kỹ năng

`MicroSkill.tsx` phục vụ cả Reading, Listening, Writing và Speaking, nên yêu cầu *"làm cả với 3 trang tương tự của 3 kỹ năng còn lại"* đã được đáp ứng bởi chính một chỗ sửa. Không có bản sao nào của bộ lọc này cần sửa theo.

## 46. Mở khoá bài tập theo thứ tự (SRC-923, 20.09.2026)

### 46a. Hai cửa, không phải một

**Bậc**: 1-3 mở sẵn; bậc 4 mở khi làm xong hết bậc 3, bậc 5 khi xong hết bậc 4. **Bài trong bậc**: chỉ bài đầu mở sẵn, bài sau mở khi bài liền trước đã xong.

Bậc 1-3 mở sẵn vì chúng là cùng một thao tác tư duy ở ba lớp ngôn ngữ (Việt · Việt rồi Anh · Anh, xem §5f): một learner đọc tiếng Anh trôi chảy không có lý do gì phải đi qua bậc 1 mới chạm được bậc 3. Bậc 4 và 5 đổi **độ dài** chứ không đổi ngôn ngữ, và vào thẳng đó khi chưa giữ nổi thao tác trên đoạn ngắn là học sai thứ.

"Đã xong" đọc từ số lần làm (`ielts-micro-attempts`), không từ tiến độ dở: nếu tính cả bài mở dở thì mở đủ mười bài là mở khoá cả bậc mà chưa làm câu nào.

### 46b. Luật ở một chỗ, thi hành ở hai chỗ

`apps/learn/src/ielts/unlock.ts` là hàm thuần, không React và không mạng. Lưới chọn bài khoá thẻ (thẻ khoá **không** bọc trong `Link`, nên Cmd+click cũng không lách được); trang làm bài hỏi lại cùng hàm ấy để chặn người gõ thẳng URL - bài đã làm một lần là đã nằm trong lịch sử trình duyệt.

Đọc không được danh sách bài thì **mở**: đây là một luật sư phạm, không phải một cổng an ninh, và để lỗi mạng chặn learner khỏi bài của chính họ là đổi sai loại rủi ro.

Bậc rỗng không chặn bậc sau - nếu không, một lỗ nội dung sẽ khoá cứng bậc 5 vĩnh viễn.

## 52. Dấu trạng thái trên mỗi thẻ việc (SRC-932, 21.09.2026)

### 52a. Trạng thái đã có từ §43; thứ thiếu là một chỗ cho MẮT

§43 (SRC-919) đã dựng đủ ba trạng thái và đủ đường dữ liệu: `ielts-micro-attempts` cho "đã xong", `ielts_micro_progress` cho "đang dở". Thứ chỉ đạo này nói tới không phải một dữ liệu còn thiếu, mà là **cách nói**: trạng thái ấy đang được nói bằng một dòng chữ nhỏ ở đáy thẻ ("In progress · 3/5", "✓ Done once") và bằng một sắc viền nhạt. Cả hai kênh đều đòi learner đọc kỹ TỪNG thẻ, trong khi việc họ đang làm là quét một lưới sáu tới mười thẻ để tìm chỗ bỏ dở. Chủ dự án nhìn đúng màn hình ấy và không thấy trạng thái nào - đó là phép đo.

`SetMark` (`apps/learn/src/ielts/SetMark.tsx`) là một ô 28px đứng cạnh tên việc, mang bốn hình:

| Trạng thái | Hình | Màu |
| --- | --- | --- |
| Khoá | đĩa mang ổ khoá | trung tính |
| Chưa làm | vòng tròn nét đứt | `--muted-foreground` 50% |
| Đang dở | vòng tròn tô một phần đúng bằng phần việc đã chấm | coral (`--accent`) |
| Đã xong | dấu tích nét trần | coral (`--accent`) |

Chữ không mất đi, nó lui về đúng phần hình không nói được: **mấy lần** ("Done 3×") và **còn bao nhiêu** ("3/5 answered", "2/3 parts"). Chữ "In progress" và dấu ✓ trong nhãn thì bỏ, vì cái dấu vừa nói xong đúng hai điều ấy và nói to hơn.

### 52b. Ô dấu có mặt kể cả khi chưa làm

Một dấu lúc có lúc không thì mắt phải dừng lại ở từng thẻ để phân biệt "chưa làm" với "màn hình chưa kịp vẽ" - và lúc ấy cái dấu lấy mất đúng thứ nó sinh ra để cho. Vòng nét đứt là một chỗ trống CÓ CHỦ Ý, khác hẳn một khoảng trắng.

Vòng ấy vẽ bằng `--muted-foreground` chứ không bằng `--border`: trong thế giới tối (`n12-deep`, nơi cả sân luyện này sống) `--border` là trắng 16%, trên mặt thẻ gần như không nhìn thấy - mà một cái dấu không nhìn thấy thì không phải là một trạng thái.

Thẻ đề NIÊM PHONG (§50) không mang dấu: "mock" là một LOẠI thẻ chứ không phải một trạng thái của learner, và nhãn "Mock - sit it once" của nó nói đúng điều cần nói.

### 52c. Bốn hình, một màu: phân biệt bằng HÌNH chứ không bằng sắc độ (SRC-934, 21.09.2026)

`--success` / `--state-solid` là màu của **mastery** - "đã vững ở năng lực này", thang 5 bậc. *"Tôi đã làm bài này rồi"* không phải *"tôi đã vững"*: một learner làm xong một bài và sai sạch vẫn là đã làm xong. Mượn màu mastery cho một phép đếm lượt là dạy mắt learner một nghĩa sai, rồi nghĩa ấy va vào chính thang mastery ở thẻ "Where am I strong?" của cùng sân luyện này.

Coral thì đúng vai: nó là điểm màu duy nhất của hệ, nghĩa là "learner có việc ở đây".

Vậy "xong" và "đang dở" cùng coral thì phân biệt bằng gì? **Bằng hình, không bằng sắc độ.** Một vòng tròn tô dở khác hẳn một dấu tích, và khác cả ở cỡ nhỏ lẫn từ khoảng cách của cả lưới. Đó đúng là thứ bản SRC-919 thiếu: nó phân biệt hai trạng thái bằng `bg-accent/10` với `bg-accent/5`, mà mắt không đọc được "đậm hơn một chút" khi hai thẻ cách nhau nửa màn hình. Sửa đúng chỗ hỏng là đổi KÊNH, không phải đổi màu.

Dấu tích vẽ **nét trần**, không đĩa không viền: một đĩa đặc ở góc mỗi thẻ đã xong là một mảng màu lặp lại sáu tới mười lần một màn, đòi sự chú ý đúng bằng thẻ đang dở - trong khi việc của một bài đã xong là lùi lại. Ô 28px bao quanh giữ nguyên cho cả bốn trạng thái, để các dấu thẳng hàng nhau dù hình bên trong to nhỏ khác nhau.

Không hình nào dùng đỏ: REQ-UX-03.

§49c đã ghi thành luật rằng khu luyện đề và lưới bài tập phải nói cùng một ngôn ngữ hình. Nên `ExamTests.tsx` đổi CÙNG lượt, dùng CÙNG component - không phải một bản chép. Đó là lý do `SetMark` đứng riêng một file thay vì nằm trong `MicroSkill.tsx`: một bản chép là cách cái luật ở §49c hỏng lặng lẽ vào lần sửa màu tiếp theo.

### 52c-bis. Đĩa ĐẶC cho hai trạng thái đáng nhìn, và một tooltip (SRC-991, 22.09.2026)

Đây là một lượt **đảo lại** hai quyết định của §52c, và cần ghi rõ vì sao chứ không chỉ ghi rằng đã đảo.

§52c chọn dấu tích **nét trần** với lý do "một đĩa đặc lặp lại sáu tới mười lần một màn đòi sự chú ý đúng bằng thẻ đang dở, trong khi việc của một bài đã xong là lùi lại". Lo ấy có thật, nhưng nó trả lời sai câu hỏi. Thứ learner quét tìm trên lưới này không phải *"chỗ nào yên tĩnh"* mà là **"chỗ nào tôi vào được BÂY GIỜ"** - và bản cũ trả lời câu ấy bằng một **vòng nét đứt mờ**, tức là đặt hình yếu nhất trong cả bộ lên đúng trạng thái đáng mời gọi nhất. Ảnh chụp của chủ dự án cho thấy chính điều đó: ba thẻ đã xong và một thẻ mở sẵn, mà thẻ mở sẵn là thẻ khó nhìn ra nhất.

Bảng mới:

| Trạng thái | Hình | Màu |
| --- | --- | --- |
| Khoá | đĩa **viền rỗng** mang ổ khoá | trung tính |
| Sẵn sàng làm | **đĩa đặc** mang mũi tên bắt đầu | nắng (`--n12-sun-400`) |
| Đang dở | vòng tròn tô một phần đúng bằng phần việc đã chấm | coral (`--accent`) |
| Đã xong | **đĩa đặc** mang dấu tích | coral (`--accent`) |

Luật đứng sau bảng: **hai trạng thái đáng nhìn đều đặc, và chúng khác nhau ở MÀU chứ không ở độ đậm.** Nắng = "vào đây được rồi", coral = "đã qua". Trạng thái không đáng nhìn (khoá) là trạng thái duy nhất còn viền rỗng. Cách phân biệt bằng hình của §52c vẫn giữ giữa "xong" và "đang dở" - một đĩa đặc khác hẳn một vòng tô dở.

Màu nắng là màu **cảnh báo** của hệ, không phải màu mastery, nên nó không va vào thang năm bậc ở thẻ "Where am I strong?" theo cách `--success` đã từng va (§52c). Vẫn không hình nào dùng đỏ (REQ-UX-03).

Nét bên trong đĩa vẽ bằng `--background` chứ không bằng `ocean-900`: trong thế giới tối `ocean-900` đã bị kéo về bản SÁNG, nên nó thành nét sáng trên nền nắng sáng - đo được 1,74:1, và cổng AS-09.3.1 bắt được. `--background` là màu nền tối của chính thế giới ấy nên luôn là nét đậm, trên cả nắng lẫn coral.

**Tooltip là lớp chữ, không phải lớp nghĩa.** Mỗi đĩa mang một nét bên trong - mũi tên cho "bắt đầu", dấu tích cho "đã xong" - vì người dùng **chạm** không hover được bao giờ. Một hình chỉ có nghĩa khi hover là một hình không có nghĩa trên điện thoại.

Hai chuỗi cho mỗi trạng thái, khác thứ tiếng và đó là chủ đích:

| | Thứ tiếng | Đi đâu |
| --- | --- | --- |
| Nhãn trạng thái (`Locked`, `Not started`, `In progress, 1 of 4`, `Done`) | Tiếng Anh | `aria-label` |
| Câu giải thích (*"Mở rồi, vào làm được ngay."*) | Tiếng Việt | tooltip |

Nhãn tiếng Anh theo cùng họ với `Not checked` / `In progress` / `Checked` ở khắp sân luyện (SRC-834/835), và ngắn để người dùng trình đọc màn hình nghe một từ thay vì một câu dài mỗi lần lướt qua một thẻ. Câu giải thích tiếng Việt theo SRC-953: đây là chữ Nemo nói **về** bài, không phải nội dung đề.

Ô dấu nằm trong một thẻ đã là link, nên `TooltipTrigger` dùng `asChild` - một `button` lồng trong link là HTML không hợp lệ. Cả bốn nhánh đi qua **cùng một hàm bọc**: quên bọc một nhánh thì đúng trạng thái ấy im lặng mất tooltip và không cổng nào bắt được.

Gác bằng e2e (`ieltsMicroSkill.spec.ts`), vì tooltip hỏng bằng cách **không xuất hiện** - và một thứ không xuất hiện thì không ảnh chụp nào bắt được. Ca ấy kiểm luôn hai thứ tiếng đứng đúng chỗ, nên một lượt "sửa lại cho đúng luật" dịch nhầm một chiều sẽ làm nó đỏ.

### 52c-ter. Trang rủ bạn có cửa (SRC-997, 22.09.2026)

`/referral` có từ SRC-450 và đủ mọi thứ - mã riêng cho từng người, bảy mốc hoạt động, ba lá thư tự động, sáu luật chống gian lận. Cái nó thiếu là **một cửa**: nó chỉ nằm trong menu của `learn` ở tầng ngoài, nên một learner sống trong sân IELTS không bao giờ đi ngang qua nó. Một tính năng đủ mã, đủ thư, đủ mốc mà không ai vào được thì bằng không.

Mục vào menu avatar, ở **cả hai** nơi cùng lượt: hub `/ielts` (`App.tsx`) và mọi trang bên trong (`ielts/Shell.tsx`). Sửa một nơi là dựng lại đúng cái bẫy SRC-872 đã gỡ - learner đi sâu vào một bài luyện rồi mất đường, phải quay ngược ra hub mới thấy lại, và một menu đổi nội dung theo trang đang đứng thì learner không học được nó chứa gì.

Nhãn tiếng Anh như cả sân luyện (SRC-834/835), dù trang đích nói tiếng Việt: đây là một nhãn điều hướng, không phải chữ Nemo giải thích bài.

Gác bằng e2e chứ không bằng mắt: đây là một mục nằm sau một cú bấm, nên nó biến mất bằng cách không có ở đó - không ảnh chụp màn hình nào bắt được. Ca kiểm đứng ở một trang **sâu** chứ không ở hub, vì hub có menu riêng còn các trang sâu mới là chỗ learner đứng lâu nhất.

**Đích đã đổi, và cách đổi đúng hơn (SRC-1010).** Bản đầu trỏ ra `/referral` của cổng `learn` chung, dưới nhãn "Invite a friend". SRC-1010 đưa trang ấy vào **trong khung sân luyện** (`/ielts/referral`, nhãn "Referral", đứng cạnh "Membership"), nên learner không còn bị bắn ra một trang sáng màu thuộc một cổng khác giữa chừng buổi học. Mục này giữ lại vì nó ghi cái **lỗ** mà SRC-997 tìm ra - một tính năng đủ mã, đủ thư, đủ mốc mà không có cửa - còn hình dạng cái cửa thì đọc ở SRC-1010.

### 52d. Một lỗi bố cục lộ ra nhờ cái dấu

Thẻ đề ở `ExamTests.tsx` khai `flex flex-wrap items-center justify-between` nhưng xếp DỌC: `Card` của shadcn mang sẵn `flex flex-col`, và `flex-wrap` không ghi đè `flex-col` vì chúng là hai thuộc tính khác nhau. Khi thẻ chỉ có một dòng chữ thì không ai thấy; thêm cái dấu vào là nó rơi xuống dưới tên đề, giữa thẻ. Sửa bằng cách viết `flex-row` ra thay vì trông vào mặc định.

Loại lỗi này không cổng nào bắt được (typecheck xanh, build xanh, `check-shadcn-tokens` xanh) và cũng không e2e nào bắt được nếu chỉ hỏi "chữ có hiện không". Nó chỉ lộ ra khi có người dựng trang lên và nhìn.

### 52e. Gác bằng `aria-label`, không gác bằng tên class

Bộ e2e (`apps/learn/e2e/ieltsMicroSkill.spec.ts`) dựng một lưới có đủ bốn trạng thái đứng cạnh nhau - bậc 2 có ba bài (xong · dở · khoá, vì "dở" không mở khoá bài sau) và bậc 3 có một bài chưa ai đụng - rồi đọc `aria-label` của dấu kề ngay sau tên bài. Đọc theo tên bài chứ không đếm số dấu toàn trang: bốn cái dấu đặt lộn thẻ vẫn cho ra đúng bốn dấu.

## 53. Xong là chấm hết câu, và bộ lọc bậc luôn có đúng một bậc (SRC-933, 21.09.2026)

### a. Hai mốc bị gộp làm một, và đó là lý do bài xong không được ghi nhận

`MicroExercise.tsx` có hai thứ khác nhau mà bản trước gộp vào một biến `finished`:

| | Nghĩa | Việc của nó |
| --- | --- | --- |
| `allAnswered` | mọi câu đã được chấm | bài ĐÃ XONG, vào sổ |
| `atEnd` | learner đang đứng ở màn kết | một VỊ TRÍ trên màn hình |

Điều kiện ghi sổ cũ là `answered === total && index >= total`, tức là đòi thêm một cú bấm
"Tiếp →" sau câu cuối. Ai chấm xong câu cuối rồi đóng tab thì làm đủ bài mà `learning_events`
trống: thẻ vẫn báo chưa làm, và theo luật mở khoá SRC-923 thì bài kế tiếp cũng vẫn khoá. Cú bấm
ấy không thêm gì vào việc học, nên nó không được phép là điều kiện để việc học được ghi nhận.

Nay ghi sổ theo `allAnswered`; `atEnd` chỉ còn giữ màn kết. Tác dụng phụ đáng nói: learner nào
đang mắc kẹt ở trạng thái cũ chỉ cần MỞ LẠI bài - phần dựng lại chỗ dở (SRC-919) khôi phục đủ
các lựa chọn, `allAnswered` thành true ngay lúc ấy và sổ được ghi.

### a2. Ghi sổ hỏng thì KHÔNG được xoá dấu vết (SRC-940, 21.09.2026)

Sửa ở mục a vẫn chưa đủ, và phần thiếu chỉ lộ ra khi truy tiếp cùng một lời phàn nàn: chủ dự án báo lại rằng thẻ Warm-up set **vẫn** chưa được đánh dấu, và trên màn hình lúc ấy nó vẽ hình **chưa làm** chứ không phải hình đang dở. Tức là cả hai nguồn sự thật cùng trống - `learning_events` không có dòng nào, mà `ielts_micro_progress` cũng không.

Hai mảnh ghép lại thành đúng cảnh ấy:

1. `logLearningEvent` **nuốt mọi lỗi** và trả `false`. Điều đó đúng với vai của nó: learner vừa học xong, một dòng thống kê hỏng là việc của hệ thống, không phải việc của họ.
2. `clearMicroProgress` chạy **ngay trong cùng một nhịp**, không đợi xem lượt ghi có tới nơi.

Cộng lại: một lượt POST hỏng vì mạng chập hay máy chủ trả 500 sẽ xoá nốt dấu vết cuối cùng của công làm bài. Thẻ quay về "chưa làm" như thể learner chưa từng mở nó, bài kế tiếp khoá lại theo luật SRC-923, và **không có đường nào phục hồi** - kể cả mở lại bài, vì phần dựng lại (SRC-919) đọc chính cái dòng vừa bị xoá.

Nay chỉ xoá khi sổ đã nhận thật (`logLearningEvent` trả `true`). Giữ dòng chỗ-đang-dừng lại thì lần mở bài sau, phần dựng lại khôi phục đủ các lựa chọn, `allAnswered` thành true ngay lúc ấy và nhánh ghi chạy lại - **chính cái dòng ấy là cơ chế thử lại**. Đó là lý do nó phải sống lâu hơn lượt ghi, không ngắn hơn.

Gác bằng `apps/learn/e2e/ieltsProgressKeep.spec.ts`, hai vế: ép lượt POST trả 500 thì không được có lệnh xoá nào, và đường thuận thì vẫn phải xoá. Một test chỉ gác vế hỏng sẽ xanh cả khi ai đó bỏ hẳn lệnh xoá đi.

### a3. Bài đầu mỗi bậc mang seq 0, và ba tầng API đều vứt nó đi (SRC-943, 21.09.2026)

Sau hai bản sửa ở a và a2, chủ dự án báo lại lần thứ ba rằng thẻ Warm-up set **vẫn** chưa được đánh dấu. Lần này thay vì suy đoán tiếp, ta đọc thẳng D1 production (workflow `seed-data` với `scripts/noop-readonly.sql` - đường đọc không ghi gì). Sổ nói ngược hẳn với giả thuyết đang có:

```
exercise:reading.main-idea.l1.0    4 lượt
exercise:reading.tone.l1.0         3 lượt
exercise:reading.vocabulary.l1.0   3 lượt
```

Lượt làm bài **có** vào D1, đủ cả. Mọi ref đều kết thúc bằng `.0`, vì `scripts/micro-seed.mjs` đánh số bài trong một bậc **từ 0** (`let seq = 0`). Và ba chỗ trong API đều coi 0 là số không hợp lệ:

| Đường | Chỗ hỏng | Hậu quả |
| --- | --- | --- |
| `GET ielts-micro-attempts` | `filter(x => x.level > 0 && x.seq > 0)` | lượt đã làm bị bỏ khỏi câu trả lời |
| `GET ielts-micro-progress` | cùng bộ lọc ấy | nhãn "đang dở" mất, và mở lại bài không dựng lại được lựa chọn |
| `PUT` / `DELETE ielts-micro-progress` | `z.number().min(1)` | trả **400**: chỗ-đang-dừng của bài đầu bậc không lưu nổi, mà cũng không xoá nổi |

Cộng lại thành một vòng khoá kín, và đây là chỗ nó thành nghiêm trọng chứ không chỉ là một cái nhãn thiếu: theo luật mở khoá §46 (SRC-923), bài đầu mỗi bậc là bài **duy nhất** learner được phép làm lúc mới vào. Bài ấy làm xong mà hệ không ghi nhận thì **không bài nào mở khoá được nữa**. Cả sân luyện đứng yên ở bài đầu của mỗi bậc, cho mọi learner, trong nhiều ngày.

**Vì sao không cổng nào thấy.** Bộ lọc `seq > 0` được viết với ý "bỏ những ref sai hình dạng" - và ý ấy đúng, chỉ có cách diễn đạt là sai. "Ref sai hình dạng" với "con số bằng 0" là hai mệnh đề khác nhau; chúng trùng nhau cho tới đúng ngày có người đánh số từ 0. Mọi ca kiểm cũ đều viết ref với seq từ 1 trở lên, nên chúng kiểm đúng một nửa thế giới. Nay lọc theo việc **regex có khớp hay không**, và có ca kiểm cho cả seq 0 lẫn ref sai hình dạng.

**Vì sao khu luyện đề (§49) không dính.** Nó dùng sơ đồ ref khác (`<cụm>:<số bài>`) và một endpoint khác (`ielts-paper-parts`) không có bộ lọc này - đó là lý do trên cùng một màn hình, thẻ đề hiện "1/3 parts" đúng trong khi thẻ bài tập báo "chưa làm".

**Bài học để lại:** khi cái nhãn trên màn hình nói "chưa làm" mà learner nói "tôi làm rồi", hãy đọc sổ TRƯỚC khi sửa chỗ hiện. Hai bản sửa a và a2 đều đúng và đáng giữ, nhưng cả hai đều được chọn bằng suy luận từ màn hình, và không cái nào chạm tới nguyên nhân thật.

### b. Bộ lọc bậc: luôn đúng một, không có trạng thái "không lọc"

Bậc mặc định là bậc THẤP NHẤT CÓ BÀI, không phải hằng số 1: một năng lực mới soạn có thể chưa có
bài bậc 1, và một mặc định cứng sẽ mở ra một lưới trống ngay lần vào đầu tiên. Mặc định này
KHÔNG ghi lên URL - nó là lựa chọn của hệ, không của learner, và ghi nó thành một mục lịch sử là
bắt nút Back bấm hai lần mới ra khỏi trang.

Bấm lại thẻ đang bật không còn tắt bộ lọc. Thứ mà trạng thái "không lọc" bày ra là một lưới trộn
lẫn bài của cả năm bậc, xếp cạnh nhau như thể chúng thay thế được cho nhau - trong khi cả thiết
kế của mạch này là learner đi từng bậc một.

### c. Chữ trong thẻ bậc căn giữa

Căn giữa cả hai dòng, không riêng tên bậc: một tiêu đề giữa đứng trên một dòng mô tả căn trái thì
thẻ đọc ra như bị lệch, và năm thẻ cạnh nhau nhân cái lệch ấy lên năm lần.

Gác: ba test trong `apps/learn/e2e/ieltsMicroSkill.spec.ts` - một cho mặc định và cho cú bấm thứ
hai không tắt lọc, một cho việc chấm hết câu là vào sổ mà không bấm thêm, và hai test cũ đã sửa
theo hình mới (bài của hai bậc khác nhau không còn cùng hiện một lúc).

## 54. Chiều cao thẻ là một con số, không phải một kết quả (SRC-939, 21.09.2026)

### a. `auto-rows-fr` làm các hàng BẰNG NHAU, không làm chúng CỐ ĐỊNH

SRC-820 đặt `auto-rows-fr` cho cả hai lưới để hàng dưới không thấp hơn hàng trên. Nó làm đúng
việc ấy, nhưng chiều cao chung vẫn do thẻ nhiều chữ nhất quyết định - tức là do DỮ LIỆU. Cùng một
trang, hai năng lực nhỏ khác nhau, hai chiều cao thẻ khác nhau; và một tên bài dài thêm một chữ
là cả lưới cao lên. Nay hai lưới mang số đo thật: `auto-rows-[5rem]` cho hàng thẻ bậc,
`auto-rows-[10rem]` cho lưới thẻ bài. Thẻ khoá và thẻ mở dùng chung con số vì chúng đứng chung
một lưới.

Đi kèm là ba cái chặn tràn, vì thẻ cao cố định thì chữ thừa không đẩy thẻ cao lên nữa mà rơi ra
ngoài đáy: `overflow-hidden` trên thân thẻ, `line-clamp-2` cho tên bài và dòng lý do khoá,
`line-clamp-1` cho dòng thành phần dưới tên bậc.

### b. Chữ cắt ngắn ở NGUỒN, clamp chỉ là lưới an toàn

`line-clamp` cắt chữ ở chỗ hiển thị, nên nếu dựa vào nó thì nội dung mất đi lặng lẽ và không cổng
nào thấy. Vì vậy `composition` trong `apps/learn/src/ielts/levels.ts` được viết lại cho gọn trong
một dòng ở khổ hẹp ("5 Việt + 5 Anh · cùng bài" thay cho "5 đoạn Việt + 5 đoạn Anh (cùng nội
dung)"), còn clamp chỉ đỡ những khổ màn hình hẹp hơn dự tính.

`why` của cả năm bậc rút xuống dưới 15 từ, không riêng bậc 2: đoạn này đứng ngay dưới hàng thẻ và
đổi theo bậc đang chọn, nên năm đoạn dài ngắn khác nhau làm cả lưới bài bên dưới nhảy lên nhảy
xuống mỗi lần learner đổi bậc. Một trần chung cho cả năm thì chỗ ấy đứng yên; `min-h-6` giữ chỗ
cho đúng một dòng. Luật độ dài này chép vào đầu `levels.ts`, nơi người sửa chữ sẽ đọc.

Gác: `apps/learn/e2e/ieltsMicroSkill.spec.ts` - test "năm bậc là năm thẻ" đã sửa theo chữ mới,
13 ca của tệp chạy xanh trên bản build thật.

## Lịch sử quyết định

### Từ mục 5i. Năm bậc bài tập, mỗi bài một URL (SRC-850, 2026-09-19)

Chỉ đạo chủ dự án 2026-09-19: *"Click vào một trong các card (ví dụ như Main Idea hay Detail) thì cần vào một trang mới. Trong đó có nhiều bài tập, và chia theo 5 levels. Mỗi levels có khá nhiều bài tập. Learners có thể filter để chọn ra bài tập để làm bài. Mỗi bài tập đều có URL riêng biệt."*

### Từ mục 42. Ba trạng thái của một bài: chưa làm · đang dở · đã xong (SRC-919, 20.09.2026)

Chỉ đạo chủ dự án, nhìn lưới bài của một năng lực:

> *"Hệ thống cần đánh dấu 'bài nào đang làm dở', 'bài nào đã làm xong rồi'."*

### Từ mục 44. Bộ lọc bậc gọn lại, và mô tả chỉ hiện khi được chọn (SRC-921, 20.09.2026)

Chỉ đạo chủ dự án 2026-09-20, trên ảnh chụp `/ielts/skills/reading/main-idea`: *"Filter bên trên, còn 5 cards, cùng trên 1 dòng, và mỗi card là 2 dòng thôi, bỏ hết đoạn text giới thiệu dài dòng đi. Khi chọn 1 trong các card đó thì đoạn text mới hiển thị. Bỏ hết link All task types. Bỏ cả 3 tags multiple choice đi."*

### Từ mục 46. Mở khoá bài tập theo thứ tự (SRC-923, 20.09.2026)

Chỉ đạo chủ dự án 2026-09-20: *"Mặc định là chỉ level 1, 2, 3 là available, và chỉ available set đầu tiên ở trong level đó thôi. Còn lại là locked hết. Khi nào làm xong cái đầu tiên thì hệ thống mới cho phép Unlock set tiếp theo."*

### Từ mục 52. Dấu trạng thái trên mỗi thẻ việc (SRC-932, 21.09.2026)

Chỉ đạo chủ dự án 2026-09-21, trên ảnh chụp `/ielts/skills/reading/vocabulary`:

> *"Cần có icon 'checked' gì đó trên mỗi card, kiểu như card Warm-up Set, để chính mỗi learner biết là 'tôi đã làm bài đó rồi', hoặc 'tôi đang làm dở bài đó'."*

Bản đầu của mục này vẽ "đã xong" bằng `--success`, với lý do "mỗi trạng thái một màu". Chủ dự án bác lại ngay trong ngày: *"Card nào hoàn thành, thì cần có một dấu tick màu da cam nho nhỏ ở góc."* Và cách đọc ấy đúng hơn với DS-001 §2.

Chỉ đạo chủ dự án trên ảnh chụp `/ielts/skills/reading/main-idea`: *"Bài nào đã làm thì cần dùng icon hình tròn đặc da cam; cái nào đã sẵn sàng để làm thì hình tròn đặc màu gì đó, ví dụ màu vàng. Hover chuột vào thì tooltip hiển thị để learner hiểu."*

Chỉ đạo chủ dự án: *"Cần có link tới trang Referral."*

### Từ mục 53. Xong là chấm hết câu, và bộ lọc bậc luôn có đúng một bậc (SRC-933, 21.09.2026)

Chỉ đạo chủ dự án, ba việc trong một vòng xem màn hình `learn.nemo12.com/ielts/skills/reading/main-idea`:

> *"Tôi đã làm xong Warmup Set, nhưng không thấy được đánh dấu là đã làm xong."*
>
> *"Căn giữa title của 5 card, gồm các từ như Level 1, Level 2..."*
>
> *"Mặc định khi mới vào trang này thì hệ thống tự chọn Level 1 để filter. Và không thể không chọn
> filter nào. Mỗi thời điểm cần chọn duy nhất 1 filter."*

### Từ mục 54. Chiều cao thẻ là một con số, không phải một kết quả (SRC-939, 21.09.2026)

Chỉ đạo chủ dự án, một vòng xem màn hình `learn.nemo12.com/ielts/skills/reading/main-idea`:

> *"Cố định chiều cao của 5 cards levels."*
>
> *"Các dòng text ngay bên dưới Level 1, Level 2... cần không được kéo dài trên 2 dòng."*
>
> *"Độ cao các Set Cards ở dưới, cần cố định."*
>
> Và đoạn mô tả bậc 2 *"để dài không quá 15 từ"*.
