Skip to content

SDD-023 · Mở dần thông tin, song ngữ, hồ sơ bé và album ảnh ​

Một phần của SDD-023. Mở dần thông tin, câu scorecard dịch được, song ngữ phủ cả cổng, bé khác người lớn, album ảnh và luật ký hiệu.

19. Mở dần thông tin (SRC-625) ​

Chỉ đạo 2026-08-28: "màn hình này quá nhiều chi tiết và thông tin. Có thể cho ẩn 80% đi được không? Khi nào cần thì click vào vài chỗ thì thông tin sẽ hiển thị ra dần."

19.1 Luật quyết định cái gì hiện sẵn ​

Hiện sẵn = thứ trả lời "tôi nên làm gì tiếp" NGAY BÂY GIỜ. Mọi thứ khác = một dòng tiêu đề có con số, bấm mới mở.

Hệ quả cụ thể, và đây là chỗ cắt được nhiều nhất: ô rỗng không chiếm chỗ nào cả.

Màn hình cũ dành bốn thẻ to cho Pains / JTBD / Needs / Beliefs mà cả bốn đều chỉ chứa câu "Nothing yet. Tag a note … to fill this in." — bốn thẻ để nói "chưa có gì", đúng chỗ đủ cho ba ghi chú thật. Hai ô Interests / Expertise cũng vậy. Đó không phải thông tin, đó là chỗ trống có viền.

Câu hướng dẫn không mất: nó chuyển vào dấu ? và vào trạng thái mở. Nó là thứ đọc một lần để hiểu cách dùng, không phải thứ đọc lại mỗi lần mở màn.

19.2 Cái gì đóng lại ​

KhốiTrướcSau
Scorecard5 thẻ cuộn ngang, luôn hiệnMột dòng = việc gấp nhất, kèm +n ▾
Pains/JTBD/Needs/Beliefs4 thẻ luôn hiện, phần lớn rỗngMột dòng tiêu đề + tổng số. Mở ra chỉ vẽ cột có nội dung; cột rỗng rút thành một dòng chữ nhỏ kèm dấu ?
Interests / Expertise2 ô luôn hiện kể cả khi trốngChỉ hiện khi đã có nội dung
Mentors followingThẻ đầy đủDòng tiêu đề + số người
PhotosThẻ đầy đủDòng tiêu đề + số ảnh

Scorecard là chỗ đáng nói nhất. Backend đã sắp thẻ theo mức gấp (thẻ còn việc trước, điểm thấp trước), nên cards[0] chính là "việc nên làm tiếp". Năm thẻ cạnh nhau bắt người đọc tự so năm câu để tìm ra câu đó — tức là hàng thẻ giao lại đúng việc mà nó tồn tại để làm hộ. Hết việc thì không hiện gì cả: "không còn việc gì" là tin tốt, và tin tốt không cần năm thẻ để nói.

19.3 Save + Cancel lên góc trên phải ​

Đảo lại quyết định ở §18.2 sau khi thấy nó chạy thật. Biểu mẫu hồ sơ dài hơn một màn hình (mười ô cộng danh sách liên kết), nên cặp nút ở đáy trôi khỏi tầm nhìn ngay khi bắt đầu gõ — phải cuộn xuống mới lưu được, và mỗi lần cuộn là một lần có thể quên. Góc trên phải thì luôn ở đó. Hai nút vẫn cạnh nhau, đúng yêu cầu §18.

19.4 Ảnh: chỉ đổi khi đang sửa, và bấm ra MENU ​

Hai luật, cả hai đến từ cùng một tai nạn — bấm hụt vào ảnh mở thẳng hộp chọn tệp, và tệp chọn xong đè luôn ảnh cũ, không hỏi, không hoàn tác:

  1. Ngoài chế độ sửa, ảnh là ảnh. Không con trỏ tay, không lớp phủ, không bấm được.
  2. Bấm vào ảnh mở một menu, không mở hộp chọn tệp: Tải ảnh lên… / Thay ảnh khác… · Xoá ảnh · Huỷ. Menu nói rõ sắp làm gì trước khi làm.

"Xoá ảnh" chỉ hiện khi thật sự có ảnh riêng để xoá — một mục menu luôn hiện mà bấm vào không đổi gì là một mục dạy người dùng rằng menu này không đáng tin.

20. Câu của scorecard dịch được (SRC-626) ​

Đóng nốt giới hạn tự ghi ở §16.6.

20.1 Vì sao không dịch được ở bản cũ ​

action và detail là chuỗi tiếng Anh đã ghép sẵn kèm tên người và con số. Một câu đã ghép thì không dịch được: đổi sang tiếng Việt phải dịch từng mảnh rồi nối lại theo trật tự tiếng Anh, ra những câu như "Không dấu hiệu của Dũng cho 45 ngày".

20.2 Backend trả khoá, client ghép câu ​

ts
type Phrase = {
  key: string;
  params?: Record<string, string | number>;   // tên người, con số — chèn thẳng
  refs?: Record<string, Phrase>;              // câu con — dịch rồi mới chèn
};

refs tồn tại vì có câu chứa câu khác: "yếu nhất: Trang · đã có 0/6 trục" là hai câu lồng nhau, và mảnh bên trong cũng phải dịch chứ không chỉ mảnh ngoài.

Mỗi ngôn ngữ có mẫu câu riêng (apps/mentors/src/scorecardText.ts), nên tiếng Việt được viết như tiếng Việt: No sign of {name} for {days} days ↔ {days} ngày không thấy động tĩnh gì từ {name} — cùng nghĩa, khác hẳn trật tự. Đó là toàn bộ lý do file đó tồn tại thay vì một bảng từ điển từ-sang-từ.

20.3 Số ít/số nhiều nằm ở client, không ở backend ​

Quy tắc số là chuyện của từng ngôn ngữ — tiếng Việt không đổi gì theo số, tiếng Anh thì có. Backend chỉ gửi con số; client tra khoá <key>#one khi tham số đếm bằng 1.

Chỉ hai dạng (một / còn lại) vì cả hai ngôn ngữ của cổng chỉ cần thế. Ngôn ngữ có dạng "hai" hoặc "ít" thì phải thay bằng bộ chọn đầy đủ, đừng chắp thêm #two.

Bản đầu của đợt này quên mất chỗ đó và cho ra "1 days since the last note" — đúng loại lỗi mà việc gom câu về một chỗ lẽ ra phải ngăn được, nên nó có test riêng.

20.4 Bất biến chặn tái phát ​

scorecards.test.ts chốt: mọi key phải là một định danh (chữ thường, gạch nối, chấm), không phải một câu. Thêm một thẻ mới mà lỡ viết action: ph("Ask them about school") thì test đỏ ngay. Không có nó, lỗi chỉ lộ ra khi có người bật tiếng Việt và thấy một câu tiếng Anh — tức là lộ ra ở chỗ người dùng, không phải ở CI.

raw là lối thoát duy nhất có chủ đích… — ĐÃ ĐÓNG ở SRC-628. next_condition và reason của journey engine nay cũng là Phrase (SDD-026 §7b), nên không còn chỗ nào trong cổng ghép sẵn câu tiếng Anh. Kiểu Phrase chuyển sang workers/api/src/modules/families/phrase.ts để hai module dùng chung.

21. Song ngữ phủ cả cổng (SRC-627) ​

Đóng nốt giới hạn thứ hai ở §16.6. Trước đợt này bật VI cho ra một cổng Việt một nửa — Family Workspace tiếng Việt, còn Team / Albums / Testimonials / Schools / màn quan sát learner vẫn tiếng Anh. Nửa vời như vậy tệ hơn không có: người dùng không đoán được chỗ nào sẽ đổi, nên không tin công tắc.

260 khoá, 8 file. Còn đúng 8 chuỗi không dịch và đó là chủ ý: Ams, Hà Nội, THPT chuyên Hà Nội - Amsterdam, 2024-2025, https://, math, Zalo — tên riêng và ví dụ trong ô nhập. Chúng rơi về nguyên văn; "dịch" một tên riêng chỉ tạo ra một bản dịch sai để bảo trì.

21.1 tf() cho câu có chèn biến ​

tf("{n} mentors published", { n }). Khoá là nguyên câu tiếng Anh kèm chỗ chèn, nên đọc mã vẫn thấy câu thật chứ không thấy một mã khoá vô nghĩa.

Luật đi kèm: không nối chuỗi ở chỗ gọi. t("có") + n + t("mentor") chỉ đúng với ngôn ngữ đã dùng để viết nó — trật tự từ của hai ngôn ngữ khác nhau. Cùng lý do đã dẫn tới Phrase ở §20, chỉ khác là ở đây câu do client sinh nên không cần đi qua API.

21.2 Cách làm, và hai chỗ máy làm sai ​

Quét bằng script (bọc text node JSX và các thuộc tính hiển thị), rồi để trình biên dịch chỉ chỗ thiếu hook — Cannot find name 't' chính là danh sách component cần useT(). Nhanh hơn tự dò, và không bỏ sót.

Hai chỗ máy làm sai, cả hai đều do regex không hiểu cú pháp:

  1. Nuốt phải type annotation. > trong () => Promise<unknown> bị coi là dấu đóng thẻ JSX, thành () =>{t("Promise")}<unknown>. Xảy ra ở ba file. Trình biên dịch bắt ngay, nhưng nó là lời nhắc: sửa hàng loạt bằng regex trên cú pháp có cấu trúc thì cổng kiểu là thứ duy nhất giữ cho việc đó an toàn — không có nó thì đừng làm.
  2. Trùng tên t. Testimonials.tsx đã có prop tên t là một Testimonial; đặt thêm hàm dịch tên t sẽ khiến t("Close") gọi một object. Đổi tên prop thành item thay vì đổi tên hàm dịch — t cho một Testimonial vốn đã là tên tồi, và giữ t nghĩa "translate" thống nhất trong cả repo đáng giá hơn.

21.3 Một chỗ tiếng Việt lọt vào nguồn tiếng Anh ​

Nhãn gợi ý của ô Website là "vd https://anhdac.com" — vd là viết tắt tiếng Việt nằm trong mã nguồn lẽ ra 100% tiếng Anh (SRC-586). Đã đổi nguồn thành e.g. … và đưa vd … vào bản dịch, đúng chỗ của nó.

23. Bé không phải người lớn thu nhỏ (SRC-629) ​

Tự rà lại sau khi đóng xong danh sách yêu cầu, và tìm ra một hồi quy do chính §17 gây ra.

23.1 Hỏng gì ​

Khi dồn việc sửa về cột giữa, thẻ hồ sơ dùng chung một bộ trường của người lớn cho cả bé. Hậu quả trên bảng learners:

Thao tácGửi lênThực tế
Đổi tên confull_nameCột là display_name → im lặng không lưu
Sửa trường đang họcdistrict, nhãn hiện "District"Cột là current_school → không lưu, và sai cả nhãn
Tên gọi ở nhà · năm sinh · lớp—Không còn chỗ nào để sửa (ô cũ ở cột trái bị bỏ ở §17.1)
Điện thoại · Zalo · email · nghề nghiệp · nơi làm việc—Năm ô rỗng vô nghĩa cho một đứa bé

Chỉ 5 trong 10 trường của bé thật sự lưu được. Và như mọi lần trong ngày hôm nay, nó im lặng: API trả 200, người dùng gõ xong bấm Save, rồi thấy ô trống lại.

23.2 Cùng một họ lỗi, lần thứ ba ​

Đây là lần thứ ba trong ngày cùng một cơ chế: gửi một tên trường mà API không nhận thì API vẫn trả 200 và vứt dữ liệu.

  • SRC-624: CONTACT_FIELDS có, schema zod không có → chặn bằng test ở backend.
  • SRC-629: client gửi tên trường của bảng khác → test backend không thấy được, vì backend cư xử đúng.

Nên cổng chặn lần này phải nằm ở kiểu của client: ContactProfileFields và LearnerProfileFields khai đúng những cột API nhận, và thẻ nhận một PersonDraft phân biệt theo kind. Gửi full_name cho một learner nay là lỗi biên dịch, không phải một ô trống bí ẩn.

Điểm quan trọng khi hiện thực: không ép kiểu. Bản đầu của đợt này viết person.fields as ContactProfileFields & LearnerProfileFields cho tiện — đúng cái đã cho phép lỗi xảy ra ngay từ đầu. Thay bằng SharedProfileFields (bốn cột cả hai bảng đều có, migration 0161) cho phần dùng chung, còn trường riêng đọc qua person để TypeScript thu hẹp theo kind.

23.3 Bài học chung của cả ngày ​

Ba lỗi mất dữ liệu im lặng trong một ngày, cả ba đều ở ranh giới giữa hai danh sách tên trường. Mỗi lần thêm một cột là một cơ hội để hai danh sách lệch nhau, và không lần nào có gì đỏ — chỉ có người dùng gõ vào ô rồi thấy nó trống lại.

Kết luận cho các đợt sau: thêm cột thì phải kiểm cả đường đi, từ ô nhập → payload client → schema zod → allow-list → cột D1. Bốn chỗ, và cổng chặn phải nằm ở chỗ nào phát hiện được sớm nhất — kiểu cho phía client, test cho phía server.

24. Album ảnh: xem từng tấm, và ảnh có lai lịch (SRC-645) ​

24.1 Bỏ ổ khoá, bỏ nút gập ​

Ổ khoá 🔒 nói "ảnh nội bộ", nhưng mọi thứ trong cổng này đều nội bộ — một biểu tượng đúng với tất cả thì không phân biệt được gì, nó chỉ thêm một ký hiệu để mắt phải bỏ qua. Luật vẫn nguyên ở chỗ nó thi hành được: server từ chối media không phải internal (Q-146).

Nút gập cũng bỏ: album có ba đến mười ảnh, gập lại tiết kiệm hai dòng và tốn một cú bấm mỗi lần mở màn.

24.2 Bấm một ảnh thì xem hẳn tấm đó ​

Lớp phủ toàn màn hình, không phải một thẻ trong cột trái: cột trái rộng 22rem, mà thứ người ta bấm vào ảnh để làm là nhìn kỹ nó. Đóng bằng Esc hoặc bấm ra ngoài; ← → chuyển ảnh, vì xem ảnh là việc làm liên tiếp nhiều tấm chứ không phải mở-đóng từng cái. Mũi tên không chuyển ảnh khi đang gõ vào ô — lúc đó nó là để di chuyển con trỏ.

24.3 Hai cột metadata mới, và vì sao chỉ hai ​

Đã có sẵn từ 0078: caption, description, taken_at, location, alt_text. Thêm (migration 0162):

CộtVì sao
sourceẢnh này từ đâu ra. Đây là ảnh trẻ em (Q-146), nên "ai gửi, ai chụp, lúc nào" là câu hỏi đầu tiên mỗi khi có người định dùng lại một tấm. Không có cột này thì câu trả lời nằm trong trí nhớ của người tải lên, và trí nhớ đó nghỉ việc sau một năm
people_jsonMảng ["contact:<id>", "learner:<id>"]. Là metadata duy nhất biến album thành thứ dùng được trong một workspace về gia đình: nó trả lời "ảnh nào có Minh". Không có nó, album chỉ là một đống ảnh xếp theo ngày

people_json lưu JSON chứ không phải bảng nối: một ảnh có vài người, luôn đọc cùng lúc với ảnh, và không có truy vấn nào đi ngược từ người ra ảnh mà không đi qua gia đình. Cùng lý do đã chọn links_json ở 0161 và đã bỏ family_visits ở 0080.

Dừng ở hai cột. Mỗi cột là một cột có người phải điền và phải bảo trì; sáu trường trên đã trả lời đủ "ảnh gì, chụp khi nào, ở đâu, có ai, từ đâu ra".

24.4 Cổng chặn mở rộng cho hai bảng còn lại ​

PHOTO_FIELDS + PatchPhotoBody chuyển vào contactSchema.ts, và test bất biến "allow-list ↔ schema không được lệch" nay phủ cả ba bảng: family_contacts (từ SRC-624), learners và family_photos (SRC-645).

Lý do mở rộng ngay thay vì đợi: cùng một lỗi đã xảy ra ba lần trong hai ngày (§23.2). Ba lần cùng một cơ chế thì đó không còn là sơ suất, mà là một chỗ thiếu cổng chặn.

Luật ký hiệu: chỉ vẽ khi nói được điều gì (SRC-649) ​

Cột trái từng có hai ký hiệu cạnh mỗi tên: một chấm bước journey và một dấu trạng thái tài khoản. Cả hai đúng nghĩa nhưng vô dụng, vì chúng giống hệt nhau ở mọi dòng: unknown là bước mặc định của mọi người trong mọi nhà, và chưa có tài khoản là trạng thái của gần hết mọi người.

Một dấu hiệu xuất hiện ở khắp nơi thì không phân biệt được gì — nó chỉ làm dòng tên rối thêm. Nên luật là: ký hiệu chỉ xuất hiện khi nó mang một tin. Bước journey ẩn ở trạng thái mặc định; dấu tài khoản chỉ hiện khi đã nối.

Cùng luật đó áp cho hành động: thêm người vào nhà là việc làm một lần rồi thôi, nên nó là một ký hiệu + chứ không phải một thẻ thường trực với hai nút có nhãn. Không xoá hẳn — màn này không còn chế độ sửa nào khác, và dọn giao diện khác với âm thầm gỡ một việc đang dùng được.

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

Trang này chỉ chứa đặc tả đang hiệu lực; các vòng sửa của mảng này nằm ở những trang khác trong cùng thư mục, xem mục lục.