Jobs to Be Done Library
Kho Job của khách hàng theo cộng đồng, ở tokyo.conan.school/c/:slug/jtbd và công khai tại www.conan.school/jtbd.
Mỗi Set là một hoàn cảnh khách hàng (ai, đang ở đâu, đang cố hoàn thành việc gì). Trong Set là các Job. Quanh mỗi Job là bốn góc nhìn: Pains, Desired Outcomes, Existing Solutions, Opportunity Gaps.
Thứ tự đó là thứ tự của việc học, không phải của dữ liệu: hoàn cảnh → câu Job → Pain → Outcome → giải pháp đang có → khoảng trống. Đọc ngược lại thì người học nhận một kết luận mà chưa có gì để tự rút ra nó.
Chủ thể của một Set là KHÁCH HÀNG của ngành
Trang hỏi: "Khách hàng trong ngành này thật ra đang cố hoàn thành điều gì?". food là Job của người ăn, không phải của chủ quán. Đợt soạn đầu (09.09.2026) viết ngược lại, theo phía người bán, và không lớp kiểm nào bắt được: --check chỉ đếm hình dạng, audit chỉ chấm độ đa dạng. Loại lỗi này không đếm được, nên nó chỉ chặn được bằng cách đọc lại câu hỏi mà trang đang đặt ra.
Phép thử: người trong Set này trả tiền cho ai? Nếu họ nhận tiền từ ngành thì đó là Job của người bán và nó thuộc về chỗ khác.
Khi sửa một lỗi khung rộng, chạy phép thử lên từng Set trước khi bỏ: education có bảy trong mười Set vốn đã đúng khung, và bỏ hết là mất 35 Job viết đúng.
Một Gap nhắc lại Pain và Outcome của chính Job đó bằng nguyên văn, nên sửa lời một Outcome mà quên sửa trong Gap là làm đứt sợi dây mà không có gì đỏ. --check kiểm điều này.
Set vỏ và Set bỏ
"outline": true cho một Set chỉ có khung, vào D1 với status = 'outline', hiện trong danh sách của cộng đồng kèm nhãn, không có trang chi tiết và không vào sitemap. Set đầu của một cộng đồng không bao giờ được là Set vỏ: đó là Set mở miễn phí.
"retired": [...] ở gốc pack để bỏ hẳn một Set. Xoá khỏi JSON là chưa đủ, bộ biên dịch chỉ INSERT … ON CONFLICT DO UPDATE, nên hàng cũ nằm lại trong D1 và vẫn hiện trên tokyo. Migration xoá từ lá lên gốc thay vì dựa vào ON DELETE CASCADE, vì khoá ngoại của SQLite chỉ có hiệu lực khi PRAGMA foreign_keys bật. Bỏ một Set là mất bookmark của mọi người đã lưu Job trong đó.
Đường đi của nội dung
content/jtbd/<community>.json → jtbd_pack_to_sql.py → migrations/*.sql → CI áp → D1Nguồn sự thật là JSON trong git, không phải D1. Nội dung này viết tay, không engine nào sinh ra nó, nên nó theo luật chung của repo: vào repo trước, D1 sau, qua migration do CI áp. Sửa thẳng D1 thì lần biên dịch sau đè mất và không ai lần ra được nội dung đang chạy từ đâu ra.
id sinh bằng uuid5 từ slug, nên biên dịch offline được và áp lại bao nhiêu lần cũng ra đúng một trạng thái. Hệ quả phải nhớ: jtbd_bookmarks trỏ vào jtbd_jobs.id, nên đổi slug của một Job là xoá sạch đánh dấu của mọi người trên Job đó, lặng lẽ.
Lược đồ
Bảy bảng jtbd_* (họ không tiền tố, đọc bằng env.DB.prepare, không qua d1Client): jtbd_sets, jtbd_jobs, jtbd_pains, jtbd_outcomes, jtbd_solutions, jtbd_gaps, jtbd_bookmarks.
Song ngữ bằng cặp cột _vi/_en, cả hai NOT NULL. Một bảng dịch riêng sẽ cho phép tồn tại trạng thái "có tiếng Việt, thiếu tiếng Anh" mà không câu lệnh nào phát hiện được; cặp cột biến thiếu bản dịch thành lỗi ngay lúc biên dịch.
jtbd_bookmarks là bảng duy nhất trong họ này bộ biên dịch không đụng tới, sáu bảng kia dựng lại mỗi lần biên dịch.
Ranh giới trả phí: khung mở, ruột khoá
Set đầu của mỗi cộng đồng is_free = 1; các Set sau cần Pro.
Với Set khoá, phần vẫn trả về: tên Set, ngành, khách hàng, hoàn cảnh, mô tả, và đủ các câu Job. Phần không được nạp: Pains, Desired Outcomes, Existing Solutions, Opportunity Gaps.
Không nạp rồi lọc, dữ liệu không rời khỏi worker thì không có nhánh render nào làm rò được nó vào HTML. Nhờ vậy trang public là trang xem trước thật, có nội dung SSR để index, và khai isAccessibleForFree: false đúng với sự thật thay vì thành cloaking.
Gác nằm ở canOpenSet() trong communities/jtbd.ts, gọi từ mọi chỗ, cửa thành viên và cửa công khai. Viết lại điều kiện ở handler thứ hai là cách một cửa lệch khỏi cửa kia mà không có gì đỏ.
Đường dẫn
| Cửa | Đường |
|---|---|
| Thành viên | GET /api/communities/:slug/jtbd, …/jtbd/:setSlug, …/jtbd/:setSlug/:jobSlug |
| Đánh dấu | POST /api/communities/:slug/jtbd/:setSlug/:jobSlug/bookmark, GET /api/communities/me/jtbd-bookmarks |
| Công khai | GET /api/public/jtbd, …/jtbd/:setSlug, …/jtbd/:setSlug/:jobSlug |
| tokyo | /c/:slug/jtbd, /c/:slug/jtbd/:setSlug, /c/:slug/jtbd/:setSlug/:jobSlug |
| www | /jtbd, /jtbd/:setSlug, /jtbd/:setSlug/:jobSlug, và bản /en của cả ba |
Giao diện tokyo: kệ mỏng, trang Job mở dần
Mọi Set trong một cộng đồng có bốn con số gần như giống nhau, 5 Jobs / 25 Pains / 25 Existing Solutions / 10 Opportunity Gaps lặp xuống hết trang. Thứ giống nhau trên mọi thẻ không phân biệt được thẻ này với thẻ kia, nó chỉ đẩy thứ có ích xuống dưới. Nên hai màn hình JTBD của tokyo cố ý hiện rất ít lúc đầu:
Kệ Set (/c/:slug/jtbd), mỗi thẻ chỉ còn tên Set và dòng khách hàng. Không badge ngành (nó lặp đúng một chữ trên mọi thẻ của cùng một cộng đồng), không đoạn mô tả, không bốn con số. Set vỏ giữ dòng ghi chú "mới có khung": sau khi bốn con số biến mất, đó là thứ duy nhất còn phân biệt nó với một Set có ruột.
Trang một Job (/c/:slug/jtbd/:setSlug/:jobSlug), câu Job hiện nguyên, năm mục còn lại đóng lại trong Accordion của shadcn, hai tầng:
| Tầng | Mục | Ghi chú |
|---|---|---|
| 1 | Hoàn cảnh · Pains · Desired Outcomes · Existing Solutions · Opportunity Gaps | badge đếm nằm ngay trên nhãn, nên biết mục dày bao nhiêu TRƯỚC khi mở |
| 2 | từng Pain, từng Gap | Outcome và Solution không gấp thêm tầng |
type="multiple" chứ không phải "single": người học hay so một Pain với một khoảng trống, mà "single" thì mở cái này là đóng cái kia, thao tác so sánh thành ra bấm qua bấm lại. Và Desired Outcome mỗi cái đúng một câu, gấp nó lại sau một cú bấm là giấu đi thứ vốn đọc hết trong hai giây.
Bốn con số không mất, chỉ đổi chỗ: chúng nằm ở badge đếm và ở trang chi tiết của Set.
Vì sao www dùng /jtbd chứ không dùng /jobs
/jobs/:schoolSlug/:jobSlug đã là tin tuyển dụng. Hai họ nội dung khác hẳn nhau dùng chung một tiền tố là cách chắc chắn nhất để cả người lẫn máy tìm kiếm hiểu sai cả hai.
Họ nội dung đầu tiên của www song ngữ thật
jtbd_* bắt cả hai vế NOT NULL, nên pick() ở các trang JTBD không bao giờ phải rơi về tiếng Việt. Đó là lý do đây là họ duy nhất ngoài nhóm trang tĩnh được khai URL /en vào sitemap: các họ khác (courses, books) có cột _en phần lớn còn trống, khai /en cho chúng là mời máy tìm kiếm vào một trang nói tiếng Việt.
Kèm theo đó là bẫy: loader chỉ chiếu _vi thì bản /en lặng lẽ nói tiếng Việt trong khi trang vẫn trả 200. Xem i18n-www.
Cộng đồng chưa có Set thì KHÔNG hiện mục
Nội dung JTBD soạn tay theo từng cộng đồng và chưa phủ hết 18 cộng đồng. GET /api/communities/:slug trả counts.jtbd_set_count, và tokyo chỉ bày mục Jobs to Be Done khi con số đó lớn hơn 0.
Bày một mục dẫn tới trang rỗng thì tệ hơn là không bày: người bấm vào học được rằng chỗ này không có gì, và lần sau họ không bấm nữa, kể cả khi nội dung đã soạn xong. counts vắng mặt thì ẩn; mặc định an toàn là giấu một mục có thật, không phải bày một mục rỗng.
Lưu ý cho người sửa: jtbd_sets khoá theo slug đã cắt tiền tố, không theo community_id như năm bảng đếm còn lại trong cùng câu truy vấn đó, nên nó cần tham số bind thứ hai.
Đường dẫn /c/:slug/jtbd vẫn mở được kể cả khi mục bị ẩn, và hiện trạng thái rỗng. Đó là cố ý: liên kết ai đó đã gửi cho nhau không được gãy khi nội dung chưa kịp soạn.
Gác
node scripts/check-jtbd.mjsChạy trong job guards. Ba điều:
- Ngưỡng nội dung và song ngữ, gọi thẳng
--checkcủa bộ biên dịch, để gác và bộ biên dịch dùng chung một bộ luật. Viết lại ngưỡng bằng JavaScript ở gác là mở đường cho hai bên bất đồng, và khi đó CI xanh mà migration không sinh được. - Mỗi Set trong repo đã được biên dịch ra migration. Đây là chỗ hỏng im lặng thật sự: thêm Set vào JSON rồi quên biên dịch thì repo trông đầy đủ, PR xanh, review đọc thấy nội dung, và trên tokyo không có gì đổi.
- Không có pack nào thì gác ĐỎ, không xanh. Gác chạy trên tập rỗng là gác luôn xanh.
Audit: lớp kiểm thứ hai, chấm chất lượng chứ không chấm hình dạng
python3 api.conan.school/scripts/jtbd_audit.py api.conan.school/content/jtbd/*.json
python3 api.conan.school/scripts/jtbd_audit.py --self-test--check của bộ biên dịch hỏi "có đủ không, đúng định dạng không". Audit hỏi một câu khác hẳn: "có nhạt không". Với một kho hàng trăm Set thì đó mới là kiểu hỏng có thật, mỗi Set đều hợp lệ, tổng thể vẫn vô dụng vì người học đọc ba Set là rút hết bài học rồi thôi đọc. Lỗi này chỉ lộ ra khi đếm trên toàn kho, nên nó phải là một lớp riêng chạy trên tất cả pack cùng lúc.
Tám tiêu chí:
| # | Tiêu chí | Vì sao |
|---|---|---|
| 1 | Đa dạng khuôn Gap: ≥2 mỗi Set, ≤50% mỗi cộng đồng, ≤30% toàn kho | Hai Set đầu tiên có 4/5 Gap cùng một khuôn |
| 2 | Mỗi Set phải có giải pháp workaround và behavior | Cách chống chế là bằng chứng mạnh nhất rằng chưa ai bán công việc đó |
| 3 | Mỗi Set ≥1 outcome emotional, ≥1 social | Kho toàn outcome chức năng dạy rằng khách hàng là một cỗ máy tối ưu |
| 4 | ≤70% Pain ở mức high | Mọi thứ đều nặng thì không gì nặng |
| 5 | Hai Job trong một Set trùng <60% từ vựng | Bắt "năm Job nói một điều bằng năm cách" |
| 6 | Hai Job khác cộng đồng trùng <70% | Bắt soạn theo khuôn mẫu |
| 7 | Câu Job không nhắc tên sản phẩm | Solution-neutral |
| 8 | Tỉ lệ dài EN/VI trong [0.5, 2.2], chỉ áp cho văn xuôi ≥50 ký tự | Bắt bản dịch cụt mà --check không thấy |
Tiêu chí 8 chỉ áp cho văn xuôi vì nhãn ngắn ("Gia sư riêng" / "Private tutor") dao động rất mạnh, áp luật ở đó chỉ tạo báo động giả, và một gác hay kêu sai là một gác người ta học cách bỏ qua.
--self-test dựng bảy mẫu hỏng, mỗi mẫu vi phạm đúng một tiêu chí, và đòi audit bắt đủ bảy. CI chạy nó như một bước riêng: audit quyết định nội dung có được vào kho hay không, nên nó phải tự chứng minh trước.
Mười khuôn Gap và cách chọn khuôn: xem skill jtbd-library.
Nội dung
Nguyên tắc soạn, tám khuôn Opportunity Gap, và bốn loại Existing Solution: xem skill jtbd-library.
Ngưỡng tối thiểu mỗi Set: 5 Job; mỗi Job 5 Pain, 5 Desired Outcome, 5 Existing Solution; mỗi Set ít nhất 2 Opportunity Gap. Không có gác bắt đủ 10 Set cho một cộng đồng, cộng đồng nào bao nhiêu Set cũng xuất bản được (quyết định 09.09.2026).
Trang một Set: tối giản (27.09.2026)
Theo người soạn, trang /c/:slug/jtbd/:setSlug giữ đúng ba thứ: tên Set, một dòng mô tả, danh sách câu Job (bấm vào để đọc chi tiết). Đã gỡ khối Khách hàng/Hoàn cảnh (trùng ý với mô tả), số thứ tự, tiêu đề "Jobs" và dòng đếm "Pains · Desired Outcomes · Existing Solutions · Opportunity Gaps", mọi Job đều cùng bộ đếm nên dòng đó không giúp chọn, chỉ làm màn hình quá tải. Số liệu chi tiết vẫn ở trang Job. Thêm lại thông tin vào trang Set thì hỏi người soạn trước.