Tính ngày không chỉ là phép trừ

Ngày tháng trông giống dữ liệu có thứ tự, vì vậy cách làm đầu tiên thường là đổi hai ngày thành timestamp rồi chia cho 86.400.000. Công thức này cho kết quả đúng trong nhiều demo và sai ở đúng những tình huống người dùng cần tin tưởng nhất: đổi múi giờ, daylight saving, ngày không tồn tại, quy tắc tính bao gồm hai đầu mút và lịch nghỉ theo địa phương. Một tiện ích ngày tháng tốt bắt đầu bằng việc phân loại câu hỏi, không bắt đầu bằng timestamp.

“Từ 1/10 đến 3/10 là bao nhiêu ngày?” có thể có đáp án 2 nếu đo khoảng cách, hoặc 3 nếu đếm cả ba ngày trên lịch. “Sau 30 ngày làm việc” lại cần cuối tuần và danh sách ngày nghỉ. Tính Ngày phải giữ những ý nghĩa này riêng biệt trong domain model và nói rõ quy tắc ngay trên kết quả.

Chọn kiểu dữ liệu đúng

Một ngày không có giờ nên được lưu như LocalDate, không phải instant lúc nửa đêm. Instant đại diện cho một điểm tuyệt đối trên dòng thời gian; LocalDate đại diện cho một ô trên lịch. Biến ngày sinh, hạn hợp đồng hoặc ngày nghỉ thành midnight instant sẽ vô tình gắn nó với timezone và có thể khiến giá trị lùi sang ngày hôm trước khi hiển thị ở nơi khác.

DateRange = { start: LocalDate, end: LocalDate, inclusion: "exclusive" | "inclusive" }
BusinessCalendar = { weekendDays, holidays, region }

Chỉ chuyển sang instant khi nghiệp vụ thực sự có thời điểm, chẳng hạn lời nhắc lúc 09:00. Khi đó model cần cả local date, local time và zone. Offset đơn lẻ chưa đủ cho lịch tương lai vì luật daylight saving của vùng có thể thay đổi.

Bao gồm hay không bao gồm

Sai lệch một ngày thường không phải lỗi toán học mà là yêu cầu chưa được định nghĩa. Khoảng [start, end) thuận tiện cho code vì độ dài bằng số bước chuyển ngày và hai khoảng liền nhau không chồng lấp. Nhưng người dùng lập kế hoạch nghỉ phép thường muốn đếm cả ngày bắt đầu lẫn ngày kết thúc. UI nên có lựa chọn rõ ràng hoặc dùng cách gọi phù hợp với ngữ cảnh, thay vì ngầm cộng một.

  • Date difference: đo số ranh giới ngày giữa hai mốc.
  • Count dates: đếm các ngày lịch được bao gồm.
  • Add days: dịch một ngày đi N bước lịch.
  • Business days: lọc bằng một lịch làm việc được định nghĩa.

Ngày nghỉ là dữ liệu có phiên bản

Cuối tuần có thể được tính bằng quy tắc, nhưng ngày lễ cần dữ liệu theo quốc gia, năm và đôi khi theo quyết định bổ sung. Không nên hard-code một mảng vĩnh viễn trong UI. Hãy xem holiday calendar là một dataset có region, version, nguồn và thời gian cập nhật. Nếu chưa có dữ liệu cho một năm, sản phẩm nên nói rõ thay vì âm thầm coi mọi ngày là ngày làm việc.

Lịch cá nhân cũng cần được tách khỏi lịch chính thức. Người dùng có thể thêm ngày nghỉ của công ty hoặc bỏ một ngày lễ vẫn phải đi làm. Phép tính nên nhận một BusinessCalendar đã hợp nhất theo quy tắc ưu tiên, giúp test logic mà không phụ thuộc storage hay network.

Ngày không hợp lệ và phép cộng tháng

Cộng một tháng vào 31/01 là câu hỏi chính sách: trả 28/02, 03/03 hay báo không hợp lệ? Nhiều thư viện chọn clamp về ngày cuối tháng, nhưng không nên để lựa chọn của thư viện vô tình trở thành cam kết sản phẩm. Tính Ngày cần định nghĩa phép cộng theo ngày, tuần, tháng và năm, rồi kiểm thử năm nhuận, cuối tháng và chuyển năm.

addMonths(2028-01-31, 1, policy="clamp") -> 2028-02-29
addYears(2028-02-29, 1, policy="clamp") -> 2029-02-28

Nhắc việc thêm một lớp thời gian

Khi kết quả được lưu thành sự kiện hoặc nhắc việc, LocalDate phải kết hợp với giờ và timezone. Lời nhắc “9 giờ sáng ngày đó” nên giữ ý nghĩa 9 giờ địa phương ngay cả khi người dùng di chuyển, hoặc giữ nguyên instant nếu đó là cuộc gọi toàn cầu. Đây là hai sản phẩm khác nhau. Nhãn và model phải phản ánh lựa chọn đó.

Cần xử lý quyền thông báo, lịch bị thay đổi sau khi lên lịch, thiết bị khởi động lại và giới hạn background. Logic tính ngày vẫn nên thuần; bộ điều phối notification chỉ nhận kết quả đã xác định và chịu trách nhiệm đăng ký với hệ điều hành.

Property tests cho không gian ngày lớn

Một vài ví dụ không đủ cho lịch. Property-based testing có thể sinh hàng nghìn cặp ngày để kiểm tra các bất biến: khoảng cách từ A đến A bằng 0; đổi thứ tự đổi dấu nếu dùng signed distance; cộng N rồi trừ N trả lại ngày ban đầu với phép cộng theo ngày; business-day result không rơi vào ngày bị loại. Các ví dụ biên vẫn cần cho 29/02, cuối tháng và DST.

Ngoài unit test, snapshot copy cũng quan trọng. Kết quả đúng nhưng nhãn mơ hồ vẫn tạo lỗi cho người dùng. Test nên xác nhận rằng chế độ inclusive hiển thị “bao gồm ngày đầu và ngày cuối”, và kết quả loại ngày nghỉ ghi rõ lịch khu vực đang dùng.

Sự chính xác bắt đầu từ ngôn ngữ

Ứng dụng tính ngày đáng tin không phải ứng dụng có nhiều công thức nhất. Nó là ứng dụng biến câu hỏi tự nhiên thành một phép tính có định nghĩa, hiển thị giả định ngay cạnh kết quả và giữ các khái niệm LocalDate, instant, holiday calendar cùng reminder tách biệt. Khi model phản ánh đúng ngôn ngữ người dùng, phần toán thường đơn giản; khi model mơ hồ, không thư viện nào cứu được trải nghiệm.