SDD-031 — Lịch hợp nhất cho learner, phụ huynh và mentor
Chỉ đạo chủ dự án 2026-09-04: "Learners của Nemo cần biết các lịch. Bố mẹ cần biết lịch của con. Mentors cần biết lịch tham gia của mentor."
Ba câu hỏi, nhưng là một câu hỏi hỏi từ ba chỗ đứng: "từ giờ tới cuối tuần tôi có việc gì". Vì vậy đợt này KHÔNG dựng ba màn hình và ba API; nó dựng một phép đọc và ba lối vào.
1. Quyết định lớn nhất: không tự xây hệ lịch, cũng không nhúng lịch ngoài
Câu hỏi đặt ra lúc nhận yêu cầu là "tự xây hay dùng Google Calendar nhúng vào". Cả hai đều sai, vì cả hai trả lời sai câu hỏi.
Phần khó của bài toán không phải giao diện lịch, mà là biết ai thuộc về buổi nào. Google Calendar không biết learner, guardian hay mentor là gì, nên một iframe lịch Google không lọc được theo vai: muốn "lịch của con" thì phải tạo một lịch riêng cho từng gia đình và chia sẻ tay, hoặc để lịch công khai — tức là đăng lịch của trẻ em lên mạng. Nó cũng đòi người xem có tài khoản Google, trong khi cổng Nemo12 đã có phiên đăng nhập rồi.
Ngược lại, tự viết bộ nhắc lịch (đẩy thông báo, nhắc trước 10 phút, hiện trên đồng hồ) là viết lại thứ mọi điện thoại đã có sẵn và làm tốt hơn ta nhiều.
Nên đường đi là chia đôi theo đúng thế mạnh:
| Việc | Ai làm | Vì sao |
|---|---|---|
| Biết ai thuộc buổi nào, ai được thấy link lớp | Nemo12 (D1) | Chỉ ta biết quan hệ learner ↔ gia đình ↔ mentor |
| Hiển thị "sắp tới có gì" trong cổng | Nemo12 (một danh sách) | Đã có phiên, đã có design system |
| Nhắc trước giờ học, đẩy lên điện thoại | App lịch của người dùng | Qua feed ICS một chiều — §5 |
Đặt lịch 1-1 với mentor (chọn slot trống) không thuộc đợt này. Nó là bài toán khác hẳn (lịch rảnh, giữ chỗ, huỷ) và chỉ đáng làm khi có nhu cầu thật.
2. Lịch không có bảng riêng
Buổi học đã là dòng thật trong course_sessions (0074), buổi cộng đồng đã là community_events (0039). Một bảng lịch thứ ba gom hai thứ ấy lại (phương án đã cân nhắc rồi bỏ) là chép dữ liệu sang chỗ thứ hai, và từ đó trở đi mọi lần sửa giờ đều phải nhớ sửa hai nơi — đúng cái bẫy mà việc nhúng Google Calendar mắc phải, chỉ khác là mắc ở trong nhà mình.
Lịch ở đây là một cách đọc hai bảng ấy, gộp lúc truy vấn: workers/api/src/modules/calendar/service.ts.
Migration 0201_calendar.sql chỉ vá ba lỗ khiến hai bảng chưa đủ:
courseschưa biết online hay offline, học ở đâu, vào link nào → thêmmode,venue,address,meeting_url.- Không đâu ghi mentor nào phụ trách → thêm
mentor_user_id. - Chưa có đường mang lịch sang app lịch của người dùng → bảng
calendar_feed_tokens.
Bốn cột đầu có mặt ở cả khoá lẫn buổi: khoá là giá trị mặc định, buổi để NULL nghĩa là "theo khoá". Một buổi lệch khỏi nếp thường (tuần này học online, buổi cuối đổi người dạy) phải sửa được ở đúng một dòng thay vì tách khoá làm đôi. Quy tắc kế thừa viết đúng MỘT lần, trong hằng SESSION_COLS.
3. Ba vai, một endpoint
GET /v1/calendar/agenda?days=7 — client không bao giờ gửi learner_id. Tập learner suy ra từ quan hệ trong dữ liệu (scopeOf):
| Đường | Suy từ | via trên mỗi mục |
|---|---|---|
| Chính mình | learners.user_id | self |
| Con / cháu trong nhà | family_members role owner/guardian/supporter | guardian |
| Buổi mình dạy | COALESCE(session.mentor_user_id, course.mentor_user_id) | mentor |
| Sự kiện đã giữ chỗ | community_event_registrations | registered |
Gửi learner_id được tức là đoán được lịch nhà khác — nên tham số ấy không tồn tại. Đây là toàn bộ ranh giới quyền của tính năng (QG-008).
Một người vừa dạy vừa là bố của một learner thì thấy cả hai loại buổi trong một danh sách, đúng như đời thật; một buổi tới từ hai đường chỉ hiện một lần, ưu tiên self > guardian > mentor.
Link phòng học online chỉ đi kèm khi người xem có phần trong buổi đó, cắt ở tầng service chứ không phải ở giao diện: một link Meet lọt ra ngoài là một phòng có trẻ em mà người lạ vào được (REQ-SEC-02).
4. Giờ Việt Nam là múi giờ nghiệp vụ
course_sessions.session_date + start_time là giờ trần, không mang offset. Service ghép chúng thành ISO có +07:00 (isoOf), và mốc "hôm nay" tính bằng date('now','+7 hours') — dùng date('now') trần sẽ coi buổi chiều nay là đã qua trong suốt 7 tiếng đầu ngày UTC. Client chỉ nhận ISO có offset và tự đổi sang giờ máy.
5. Feed ICS — chỗ ta mượn hệ thống lịch có sẵn
GET /v1/calendar/feed/{token}.ics là route công khai duy nhất của module, và nó công khai vì bắt buộc: Google Calendar tải feed bằng máy chủ của Google, không mang cookie của ai.
Bù lại, quyền nằm trong chính địa chỉ:
- token 32 byte từ CSPRNG, không chứa
user_id— nhặt được địa chỉ cũng không suy ra nhà nào; - rút lại được (
revoked_at), và token sai với token đã rút trả 404 giống hệt nhau, để không xác nhận token nào từng tồn tại; - trần 10 feed một người, đủ cho mỗi thiết bị một feed;
- không ai rút được feed của người khác kể cả khi biết token (
AND user_id = ?).
Feed nhìn xa 60 ngày trong khi trang web mặc định 7: app lịch tải mỗi vài giờ, và người ta mở lịch tháng sau để xếp việc. UID ổn định qua các lần tải (session-<id>@nemo12.com) — sinh ngẫu nhiên mỗi lần thì mỗi lượt đồng bộ đẻ ra một sự kiện trùng. Buổi huỷ giữ lại dòng với STATUS:CANCELLED thay vì biến mất, để app lịch xoá được nó khỏi máy người dùng.
6. Giao diện: một danh sách, không phải lưới tháng
CalendarPanel.tsx — canonical ở apps/marlins, hai bản sao Y HỆT ở apps/learn và apps/mentors (cùng luật với CoursesPanel).
Luật tối giản SRC-048: câu hỏi thật của cả ba vai là "sắp tới tôi có việc gì", và câu ấy trả lời bằng một dòng thời gian đọc từ trên xuống. Lưới tháng đẹp trên ảnh chụp nhưng trên điện thoại mỗi ô còn ba chữ, và không ai đọc lịch của mình bằng cách đếm ô.
Mỗi dòng mở đầu bằng một card vuông chứa thời điểm (chỉ đạo 2026-09-05): thứ, ngày dạng DD.MM, và đáy card là khung giờ trên một dòng (19:00 - 20:30). Vuông bằng aspect-square chứ không phải một chiều cao cố định: chiều cao đi theo chiều rộng, nên card không méo khi tên buổi dài ngắn khác nhau. Ngày và giờ trả lời cùng một câu hỏi — "lúc nào" — nên chúng thuộc về một khối; bản trước tách chúng ra hai chỗ (ngày ở tiêu đề nhóm phía trên, giờ ở một cột trong thẻ) khiến mắt phải nhảy hai lần mới ghép được một mốc thời gian. Vì mỗi dòng đã tự mang ngày nên không còn tiêu đề nhóm theo ngày: giữ lại là viết cùng một ngày hai lần trên một màn hình.
Ba lối vào: marlins /calendar (Lịch của con, kèm thẻ xem trước trên trang chủ) · learn /calendar (Lịch của mình) · mentors /calendar (Lịch dạy).
8. Nhập dữ liệu: màn quản trị khoá học
Năm cột mà 0201 mở ra không có màn nào nhập, nên lúc đầu chúng phải seed bằng SQL tay — tức là đổi phòng Zoom cũng thành một pull request. Tab Courses ở admin là chỗ đội vận hành tự làm (workers/api/src/modules/admin/courses.ts, apps/admin/src/pages/Courses.tsx).
Ba quyết định đáng ghi lại:
- Không chỉ có "hai ô".
meeting_urlchỉ có tác dụng khimode='online', cònvenue/addresslà thứ thay thế nó khi học trực tiếp. Cho nhập link mà không cho đặt hình thức thì cái link ấy không bao giờ hiện ra với ai — nên màn hình hiện đúng bộ ô đang có nghĩa và giấu bộ kia. undefinedkhácnull. Trường không gửi lên nghĩa là "không đụng tới"; gửi lên rỗng nghĩa là "xoá". Trộn hai thứ ấy thì một cái link nhập nhầm sẽ không bao giờ xoá được. Ô trống ở buổi lưu thànhNULL, đúng nghĩa "theo khoá" màSESSION_COLSđọc.- Người dạy chọn từ danh sách, không gõ id. Backend còn kiểm lại vai mentor/staff/admin trước khi ghi — một
mentor_user_idtrỏ vào tài khoản bất kỳ sẽ lặng lẽ đẩy buổi học vào lịch của người lạ.
Mỗi lượt sửa ghi audit_log (course.updated / course_session.updated) kèm tên trường đã đổi, nhưng không kèm chính cái link — nhật ký không phải chỗ phát tán nó (AS-07.4.3).
8b. Sự kiện đang mở, và vì sao nó là một danh sách RIÊNG (SRC-811, 2026-09-18)
Chỉ đạo chủ dự án 2026-09-18: "Cần có trang Schedule hoặc Calendar, trong đó có lịch học, và lịch các Sự kiện liên quan tới IELTS."
Trang lịch đã có từ SRC-678, nhưng nó chỉ hiện buổi cộng đồng người dùng đã giữ chỗ. Với một sự kiện MỚI, điều đó tạo ra một vòng tròn tự đóng: learner mở lịch ra không thấy gì, nên không biết có buổi nào để giữ chỗ, nên không giữ chỗ, nên buổi ấy mãi mãi không xuất hiện. Không có lỗi nào, và không có chỗ nào để nhận ra.
GET /v1/calendar/open-events trả buổi đang mở mà người này chưa giữ chỗ, via: "open".
Bản đầu trộn thẳng vào agenda, và bộ test bắt được hai chỗ hỏng — cả hai đều đúng:
agendalà "việc CỦA TÔI". Một buổi chưa giữ chỗ thì chưa phải việc của ai; trộn vào là đổi nghĩa của cả danh sách, và mọi chỗ đang đọc nó đổi nghĩa theo mà không ai khai báo. Các test cũ đỏ ngay vì chúng khẳng định danh sách chính xác.agendacòn là nguồn của feed ICS (§5). Trộn vào nghĩa là đẩy những buổi người ta chưa hề ghi tên vào thẳng lịch điện thoại của họ, kèm chuông nhắc trước 10 phút.
Hai chốt giữ cho nó không thành bảng quảng cáo: chỉ buổi chưa huỷ và chưa giữ chỗ, và trần 5 buổi trong cửa sổ đang xem. Sự kiện mở là một lời mời, và một lời mời lặp hai chục lần thì thành thứ người ta học cách bỏ qua.
Trên màn hình chúng nằm ở một khối riêng có tiêu đề riêng, dưới lịch của mình, kèm câu "Chưa có trong lịch của bạn" và một nút Giữ chỗ gọi thẳng POST /v1/events/{id}/register. Trộn chung một danh sách là mời người đọc nhầm một lời mời thành một cái hẹn, và cái nhầm ấy chỉ lộ ra vào đúng sáng hôm diễn ra.
Khối này chỉ bày ở màn của học sinh: CalendarPanel nhận loadOpen là tuỳ chọn, và màn mentor không truyền nó.
community_events.meeting_url (migration 0236). Bảng có mode='online' từ đầu nhưng không có chỗ đặt link phòng họp, nên service phải trả meeting_url: null kèm một chú thích nói thẳng rằng nó đang thiếu. Một sự kiện online mà lịch không mang nổi đường vào thì đến giờ G người ta phải đi tìm link ở chỗ khác. Cột để NULL cho tới khi có link thật: đặt một link đoán tệ hơn để trống.
9. Bảng đối chiếu
| REQ | Ở đâu trong tài liệu này |
|---|---|
| REQ-CAL-01 (learner xem lịch của mình) | §3, §6 |
| REQ-CAL-02 (phụ huynh xem lịch của con) | §3, §6 |
| REQ-CAL-03 (mentor xem buổi mình dạy) | §2, §3 |
| REQ-CAL-04 (mang lịch sang app lịch cá nhân) | §1, §5 |
| REQ-CAL-05 (link lớp chỉ cho người trong buổi) | §3 |
| REQ-CAL-06 (màn quản trị nhập các cột ấy) | §8 |