Tiến độ bài tập ở sân luyện IELTS
Learner mở một lưới sáu tới mười thẻ và hỏi đúng một câu: "tôi đang dở chỗ nào?" Cả mạch dưới đây tồn tại để trả lời câu ấy trong một cái liếc - và để câu trả lời không bao giờ nói dối.
Thiết kế và lý do từng quyết định: SDD-038 §42 · §46 · §52 · §53 · Code: apps/learn/src/ielts/SetMark.tsx (hình), MicroSkill.tsx (lưới), MicroExercise.tsx (ghi), workers/api/src/modules/ieltsSkills/routes.ts (API)
1. Bốn trạng thái của một bài
| Trạng thái | Hình trên thẻ | Đọc từ đâu |
|---|---|---|
| Khoá | đĩa xám mang ổ khoá | tính tại chỗ, unlock.ts |
| Chưa làm | vòng tròn nét đứt | không có dòng nào ở cả hai sổ |
| Đang dở | vòng tròn coral tô một phần | ielts_micro_progress |
| Đã xong | dấu tích coral, nét trần | learning_events (action='practice') |
Cả bốn dùng chung một ô 28px ở cùng một chỗ trên mọi thẻ, kể cả trạng thái "chưa làm". Ô luôn có mặt là có chủ ý: một dấu lúc có lúc không thì mắt phải dừng ở từng thẻ để phân biệt "chưa làm" với "màn hình chưa kịp vẽ".
Màu: coral cho cả "đang dở" lẫn "đã xong", phân biệt bằng hình chứ không bằng sắc độ. Xanh --success là màu của mastery ("đã vững ở năng lực này"), không phải của một phép đếm lượt - làm xong một bài và sai sạch vẫn là đã làm xong.
2. Hai cuốn sổ, và vì sao không gộp
| Bảng | Một dòng nghĩa là gì | Vòng đời | |
|---|---|---|---|
| Đã xong | learning_events | một LƯỢT đã hoàn tất | chỉ thêm, không bao giờ xoá |
| Đang dở | ielts_micro_progress | chỗ learner đang đứng + các lựa chọn đã chấm | xoá khi bài xong |
"Đã xong" đếm được nhiều lần (bài ở đây cố ý làm đi làm lại cho thành phản xạ), nên nó là một cuốn sổ chỉ-thêm. "Đang dở" thì mỗi cặp (learner, bài) nhiều nhất một dòng, và dòng ấy chết khi bài xong - trạng thái xong đã có nguồn sự thật riêng, 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.
Bảng ielts_micro_progress giữ cả picks (JSON {item_id: chỉ số đã chọn}) chứ không chỉ một con số: một thẻ báo "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ì.
3. Địa chỉ của một bài: ref
exercise:<skill>.<mã năng lực>.l<bậc>.<thứ tự>
ví dụ exercise:reading.main-idea.l1.0Cả hai bảng dùng chung một quy ước này (migration 0267 ghi rõ điều đó), nên "đã xong" và "đang dở" luôn nói về cùng một bài.
Thứ tự đếm từ 0
scripts/micro-seed.mjs đánh số bài trong một bậc từ 0: bài đầu mỗi bậc là .0. Mà theo luật mở khoá (§4 dưới đây), bài đầu là bài duy nhất learner được phép làm lúc mới vào.
Ba chỗ trong API từng coi 0 là số không hợp lệ (filter(seq > 0) ở hai đường đọc, z.number().min(1) ở đường ghi). Hậu quả không phải một cái nhãn thiếu: lượt làm bài vào D1 thật, API im lặng bỏ đi, không bài nào mở khoá được nữa, và cả sân luyện đứng yên ở bài đầu mỗi bậc cho mọi learner trong nhiều ngày. Xem SDD-038 §53a3.
Luật rút ra: lọc theo việc regex có khớp hay không, tuyệt đối không theo giá trị con số. "Ref sai hình dạng" và "con số bằng 0" là hai mệnh đề khác nhau, và chúng chỉ trùng nhau cho tới ngày có người đánh số từ 0.
4. Mở khoá: trạng thái không chỉ là một cái nhãn
Hai cửa, không phải một (apps/learn/src/ielts/unlock.ts):
- Bậc: bậc 1-3 mở sẵn; bậc 4 mở khi 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.
"Đã xong" ở đây đọc từ sổ lượt, không từ sổ đang-dở: nếu không, mở đủ mười bài rồi bỏ đấy là mở khoá cả bậc mà chưa làm câu nào.
Vì mở khoá ăn theo trạng thái, một lỗi ở đường đọc trạng thái là một lỗi chặn đường học, không phải một lỗi hiển thị. Đó là điều làm mạch này đáng gác kỹ hơn vẻ ngoài của nó.
5. Đường ghi
learner chấm câu cuối
→ allAnswered = true
→ POST /v1/learners/{id}/learning-events (action=practice, ref=…)
→ CHỈ KHI lượt ghi trả về ok: DELETE ielts-micro-progressBa điều đã thành luật, mỗi điều trả giá một lần mới có:
- Ghi theo
allAnswered, không theoatEnd. Bản cũ đòi bấm thêm "Tiếp →" sau câu cuối; ai chấm xong rồi đóng tab thì làm đủ bài mà sổ trống. Cú bấm ấy không thêm gì vào việc học nên không được phép là điều kiện để việc học được ghi nhận. - Chỗ đang dừng ghi sau MỖI câu được chấm, không đợi lúc rời trang: learner rời bằng cách đóng tab nhiều hơn bằng một nút Thoát, và
beforeunloadvừa không chạy trên di động vừa không gửi kịp. - Lượt ghi hỏng thì KHÔNG xoá dòng đang-dở.
logLearningEventnuốt mọi lỗi (đúng với vai của nó), nên nếu xoá vô điều kiện thì một lượt POST hỏng sẽ xoá nốt dấu vết cuối cùng của công làm bài, và không có đường phục hồi. Giữ dòng ấy lại thì lần mở bài sau, phần dựng lại khôi phục đủ lựa chọn,allAnsweredthành true và nhánh ghi chạy lại - chính cái dòng ấy là cơ chế thử lại.
6. Đường đọc
| Endpoint | Trả về | Hỏng thì |
|---|---|---|
GET /v1/learners/{id}/ielts-micro-attempts?skill=&code= | [{level, seq, attempts, last_day}] | client trả mảng rỗng |
GET /v1/learners/{id}/ielts-micro-progress?skill=&code= | [{level, seq, answered, total, picks}] | client trả mảng rỗng |
PUT /v1/learners/{id}/ielts-micro-progress | ghi chỗ đang dừng | nuốt lỗi |
DELETE /v1/learners/{id}/ielts-micro-progress?… | xoá chỗ đang dừng | nuốt lỗi |
Số lượt đếm từ learning_events chứ không dựng bảng đếm riêng: một bảng như vậy sẽ là bản sao thứ hai của một sự thật đã có. Đổi lại, con số tự đúng cho cả những lượt đã làm từ trước khi tính năng này ra đời.
Một chỗ yếu đã biết, chưa sửa
Hai đường đọc nuốt lỗi và trả mảng rỗng. Với một cái nhãn thì đó là lựa chọn đúng. Nhưng cùng mảng ấy nuôi luật mở khoá, nên một lần API chập đọc ra thành "learner chưa làm gì" và cả lưới khoá lại, không có gì nói cho learner biết đó là lỗi mạng chứ không phải lỗi của họ. Sửa đúng là "hỏng thì mở, đừng khoá" - phân biệt "không có dữ liệu" với "không đọc được".
7. Cùng ngôn ngữ hình ở khu luyện đề
Khu luyện đề (ExamTests.tsx) dùng chung component SetMark và chung bảng màu, nhưng đọc từ một nguồn khác: GET ielts-paper-parts trả ref thô dạng <cụm>:<số bài>, vì một "đề" là phép ghép ba bài đọc hoặc bốn đoạn nghe, và phép ghép ấy sống ở data/examTests.ts chứ không ở máy chủ.
Hai khu phải nói cùng một ngôn ngữ hình - hai bảng màu khác nhau cho cùng một ý là bắt learner học hai lần cùng một điều. Đó là lý do SetMark đứng riêng một file thay vì nằm trong trang lưới: một bản chép là cách luật ấy hỏng lặng lẽ vào lần sửa màu tiếp theo.
8. Gác bằng gì
| Phép kiểm | File | Bắt được điều gì |
|---|---|---|
| Bốn trạng thái trên một lưới | apps/learn/e2e/ieltsMicroSkill.spec.ts | dấu vẽ nhầm hình, hoặc đặt lộn thẻ (đọc qua aria-label, theo tên bài) |
| Ghi hỏng thì không xoá | apps/learn/e2e/ieltsProgressKeep.spec.ts | cả hai vế: ép POST trả 500 thì không có lệnh xoá, đường thuận thì vẫn xoá |
seq = 0 đi qua được API | workers/api/src/modules/ieltsSkills/routes.test.ts | bài đầu mỗi bậc bị bộ lọc vứt đi, và ref sai hình dạng vẫn phải bị bỏ |
Ba lỗi của mạch này đều typecheck xanh, build xanh, và im lặng. Không lỗi nào lộ ra ở đường thuận - chúng chỉ hiện khi có người dựng trang lên nhìn, hoặc khi ép một lượt gọi hỏng. Đó là lý do phần gác ở đây nặng về e2e và về ca hỏng hơn là về unit test.
9. Một bài học về cách truy lỗi
Chủ dự án báo ba lần rằng "Warm-up set làm xong mà không được đánh dấu". Hai vòng đầu sửa hai lỗi có thật (§53a, §53a2) 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. Vòng thứ ba đọc thẳng D1 production, và sổ nói ngay: lượt làm bài có đủ, chỉ là API không trả về.
Luật rút ra: khi cái nhãn 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. Đường đọc production không ghi gì: workflow seed-data với scripts/noop-readonly.sql và một câu SELECT ở ô verify.