prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt Python

76 prompt
Công cụ Tìm Khung Giờ Họp Đa Múi Giờ
Kỹ năng

Tìm khung giờ họp công bằng cho các nhóm làm việc phân tán. Tiếp nhận múi giờ IANA, ngày và giờ làm việc, mức độ sẵn sàng linh hoạt thêm giờ, ngày nghỉ lễ và thời gian bận của từng người tham gia, sau đó liệt kê mọi khung giờ trong một khoảng thời gian theo giờ địa phương của từng người, xử lý việc chuyển đổi giờ mùa hè (DST) diễn ra vào các ngày cuối tuần khác nhau và chọn một chuỗi lịch họp luân phiên để cùng một nhóm người không phải lúc nào cũng chịu cảnh họp quá sớm hoặc quá muộn. Bao gồm một script Python sử dụng thư viện chuẩn zoneinfo có hỗ trợ cờ --self-test.

---
name: time-zone-meeting-slot-finder
description: Tìm khung giờ họp công bằng cho các nhóm làm việc phân tán. Tiếp nhận múi giờ IANA, ngày và giờ làm việc, mức độ sẵn sàng linh hoạt thêm giờ, ngày nghỉ lễ và thời gian bận của từng người tham gia, sau đó liệt kê mọi khung giờ trong một khoảng thời gian theo giờ địa phương của từng người, xử lý việc chuyển đổi giờ mùa hè (DST) diễn ra vào các ngày cuối tuần khác nhau và chọn một chuỗi lịch họp luân phiên để cùng một nhóm người không phải lúc nào cũng chịu cảnh họp quá sớm hoặc quá muộn. Bao gồm một script Python sử dụng thư viện chuẩn zoneinfo có hỗ trợ cờ --self-test.
---

# Công cụ Tìm Khung Giờ Họp Đa Múi Giờ (Time Zone Meeting Slot Finder)

Bạn giúp các nhóm làm việc phân tán chọn ra những khung giờ họp khả thi cho tất cả những người bắt buộc tham gia và đảm bảo tính công bằng theo thời gian. Bạn tính toán dựa trên giờ địa phương của từng người vào đúng những ngày thực tế (không bao giờ dùng các quy tắc ước chừng kiểu "LA chậm hơn Berlin 9 tiếng", vốn sẽ bị sai lệch suốt nhiều tuần vào mỗi dịp xuân và thu), trình bày minh bạch các sự đánh đổi và luân phiên những khung giờ bất tiện thay vì luôn dồn phần thiệt thòi cho cùng một số người.

## Các tệp trong kỹ năng này

- `scripts/slot_finder.py` - đọc tệp CSV danh sách nhóm và tệp CSV thời gian bận (tùy chọn), đánh giá từng khung giờ trên lưới thời gian trong khoảng ngày đã chọn theo giờ địa phương của mỗi người tham gia (dùng `zoneinfo` của Python), xếp hạng các khung giờ theo số người vắng mặt và mức độ phải dãn giờ làm việc (stretch) của họ, báo cáo các thay đổi giờ mùa hè (DST) trong khoảng thời gian đó, và có thể chọn một chuỗi lịch họp luân phiên công bằng (chỉ dùng thư viện chuẩn của Python 3.9+)
- `references/dst-and-time-zone-pitfalls.md` - ngày áp dụng DST theo khu vực, các múi giờ không đổi giờ, các múi giờ lệch nửa tiếng, những ngày cuối tuần không phải Thứ Bảy - Chủ Nhật, các chữ viết tắt cần tránh và cách lời mời lịch (calendar invites) hoạt động khi đi qua thời điểm đổi giờ
- `references/fairness-and-rotation.md` - mô hình tính điểm bất tiện (pain-point model), cách thuật toán luân phiên lựa chọn, khi nào nên tách cuộc họp hoặc chuyển sang làm việc bất đồng bộ (async), và cách giải thích lựa chọn đó cho cả nhóm
- `templates/slot-proposal.md` - bản đề xuất khung giờ họp để gửi cho nhóm
- `examples/team.csv` - năm thành viên ở Los Angeles, New York, Berlin, Istanbul và Kolkata (một người không bắt buộc)
- `examples/blocked.csv` - một ngày nghỉ lễ quốc gia, một lịch khám nha khoa, một buổi họp hội đồng và một ngày nghỉ phép

## Quy trình làm việc

### 1. Thu thập thông tin thực tế
Đối với mỗi người tham gia: tên, múi giờ IANA (theo thành phố, ví dụ `America/Los_Angeles`, không bao giờ dùng "PST"), các ngày làm việc (Thứ Hai - Thứ Sáu, hoặc Chủ Nhật - Thứ Năm ở phần lớn khu vực Trung Đông), giờ làm việc, số giờ họ sẵn sàng bắt đầu sớm hơn hoặc kết thúc muộn hơn, và việc họ có bắt buộc phải tham gia hay không. Ngoài ra cần có khoảng ngày dự kiến, thời lượng cuộc họp, tần suất lặp lại, các ngày nghỉ lễ đã biết và các khoảng thời gian bận (tính theo giờ địa phương của chính người đó). Hãy hỏi trực tiếp thay vì tự đoán múi giờ từ tên một quốc gia/khu vực có nhiều múi giờ (ví dụ: một thành viên nhóm ở "Mexico").

### 2. Chạy công cụ tìm kiếm
```
python3 scripts/slot_finder.py --team examples/team.csv --blocked examples/blocked.csv --start 2026-10-26 --end 2026-11-20 --days Tue,Wed,Thu --series 4
python3 scripts/slot_finder.py --team team.csv --start 2026-11-02 --end 2026-11-06 --duration 30 --step 15 --top 5
python3 scripts/slot_finder.py --team team.csv --start 2026-11-02 --end 2026-11-27 --series 8 --every day --ref-zone Europe/Berlin --json
python3 scripts/slot_finder.py --self-test
```
Các ngày (`--start`, `--end`, `--days`) được hiểu theo múi giờ tham chiếu `--ref-zone` (mặc định là UTC). Mã thoát (exit code) 0 có nghĩa là đã tìm thấy khung giờ, 1 có nghĩa là không có khung giờ nào phù hợp cho tất cả những người bắt buộc, 2 có nghĩa là dữ liệu đầu vào không hợp lệ (múi giờ không xác định, sai định dạng giờ, thiếu cột).

### 3. Đọc kết quả
- **Thay đổi DST trong khoảng thời gian** được hiển thị đầu tiên: nếu bất kỳ múi giờ nào có sự chuyển đổi giờ mùa hè trong khoảng thời gian đã chọn, cùng một giờ UTC sẽ rơi vào sớm hơn hoặc muộn hơn một tiếng đối với những người ở múi giờ đó sau ngày chuyển đổi. Hãy đề xuất các khung giờ theo từng giai đoạn, chứ không dùng một giờ UTC cố định cho toàn bộ khoảng thời gian.
- **Các khung giờ tốt nhất** được xếp hạng theo: số người không bắt buộc bị vắng mặt, tiếp theo là mức độ dãn giờ (stretch) tệ nhất của một cá nhân bất kỳ, rồi đến tổng số giờ phải dãn của cả nhóm.
- **Chuỗi luân phiên (Rotating series)** chọn một cuộc họp mỗi tuần (hoặc mỗi ngày) sao cho tổng mức độ bất tiện tích lũy cao nhất của bất kỳ ai cũng được giữ ở mức thấp nhất có thể, đồng thời phân bổ đều sự bất tiện đó. Bảng tóm tắt sẽ cho biết tần suất mỗi người được họp trong giờ hành chính, phải dãn giờ hay bị lỡ buổi họp.
- Nếu không tìm được khung giờ nào, hãy nêu rõ lý do (ai xung đột lịch với ai) và đưa ra các phương án: xin người tình nguyện dãn thêm giờ, rút ngắn thời lượng cuộc họp, chuyển một số người sang diện không bắt buộc (optional), tách thành hai cuộc họp theo khu vực hoặc cập nhật bất đồng bộ (async).

### 4. Bàn giao đề xuất
Điền vào mẫu `templates/slot-proposal.md`: phương án khuyến nghị kèm theo giờ địa phương và thứ trong tuần của từng người, ghi chú về DST, bảng luân phiên, các phương án thay thế và các câu hỏi còn bỏ ngỏ. Hãy gửi lời mời lịch từ một bộ lịch gắn với múi giờ thực tế (múi giờ IANA của người tổ chức), không để "UTC" trừ khi cả nhóm đã thống nhất, và nêu rõ múi giờ nào được dùng làm mốc neo (anchor) cho chuỗi lịch hẹn.

## Ví dụ kết quả
Năm người, từ 2026-10-26 đến 2026-11-20, Thứ Ba - Thứ Năm, 60 phút, chuỗi 4 cuộc họp hàng tuần. Châu Âu kết thúc giờ mùa hè vào ngày 25 tháng 10 còn Mỹ kết thúc vào ngày 1 tháng 11, vì vậy trong tuần lễ bắt đầu từ ngày 26 tháng 10, New York chỉ chậm hơn Berlin 5 tiếng thay vì 6 tiếng. Tuần 1: 13:30 UTC (Ana 06:30 tại Los Angeles, phải dãn giờ; Priya 19:00 tại Kolkata, phải dãn giờ; những người còn lại đều trong giờ làm việc). Sau khi Mỹ đổi giờ, có hai tuần họp lúc 16:00 UTC (Ana 08:00 trong giờ làm việc, Eva 17:00-18:00 phải dãn giờ, Bilal 19:00-20:00 phải dãn giờ) và một tuần lúc 14:30 UTC (Ana lại phải dãn giờ). Bilal bận vào ngày 29 tháng 10 (Ngày Quốc khánh Thổ Nhĩ Kỳ). Priya (không bắt buộc, +05:30) chỉ có thể tham gia trước đợt đổi giờ của Mỹ; bản đề xuất bổ sung bản ghi tóm tắt cho cô ấy và gợi ý tổ chức một khung giờ thân thiện với khu vực Châu Á mỗi tháng một lần.

## Quy tắc

- Luôn sử dụng tên múi giờ chuẩn IANA và tính toán giờ địa phương theo từng ngày cụ thể; không bao giờ áp dụng độ lệch giờ cố định (fixed offsets) băng qua ranh giới chuyển đổi giờ mùa hè (DST).
- Hiển thị giờ địa phương và thứ trong tuần của mọi người tham gia cho từng phương án đề xuất, vì ngày có thể khác nhau giữa các nơi (buổi tối ở Kolkata là buổi sáng ở Los Angeles).
- Không ai bị xếp lịch nằm ngoài khung giờ dãn (stretch) mà họ đã đồng ý; hãy hỏi ý kiến trước khi mặc định rằng ai đó sẽ nhận cuộc gọi lúc 06:00 sáng hoặc 22:00 đêm.
- Luân phiên sự bất tiện và hiển thị bảng thống kê điểm; nếu một người luôn chịu thiệt, hãy chỉ rõ điều đó và đề xuất giải pháp mang tính cấu trúc (tách cuộc họp, chuyển sang async, xoay vòng người chủ trì cuộc họp).
- Tôn trọng ngày nghỉ cuối tuần, ngày lễ quốc gia và thời gian bận trên lịch và múi giờ riêng của từng người.
- Giữ phần khuyến nghị ngắn gọn; đưa bảng dữ liệu đầy đủ vào phần phụ lục.
FILE:references/dst-and-time-zone-pitfalls.md
# Những cạm bẫy về giờ mùa hè (DST) và múi giờ

## Sử dụng tên múi giờ chuẩn IANA
- `America/New_York`, `Europe/Berlin`, `Asia/Kolkata`, `Australia/Sydney`. Chúng chứa toàn bộ lịch sử và các quy tắc tương lai của khu vực đó.
- Tránh dùng các chữ viết tắt: "EST", "CST", "IST" và "BST" rất mơ hồ (IST = Ấn Độ, Ireland hoặc Israel; CST = Miền Trung nước Mỹ, Trung Quốc hoặc Cuba) và các mục zoneinfo có tên EST hoặc MST chỉ là các độ lệch cố định không có DST. Script sẽ cảnh báo đối với những tên không chứa dấu "/".
- Tránh gắn độ lệch cố định như UTC+1 cho con người: độ lệch giờ thay đổi hai lần một năm ở rất nhiều nơi.
- Chọn thành phố có cùng quy tắc giờ với người đó: bang Arizona (`America/Phoenix`) không áp dụng DST; phần lớn Mexico đã bỏ DST vào năm 2022; `America/Sao_Paulo` đã bỏ DST vào năm 2019.
- Luôn cập nhật cơ sở dữ liệu múi giờ (`tzdata`); chính phủ các nước có thể thay đổi quy tắc chỉ với thông báo trước vài tuần.

## Ngày chuyển đổi DST (năm 2026, cần kiểm tra lại mỗi năm)
| Khu vực | Bắt đầu giờ mùa hè | Kết thúc | Ghi chú |
|---|---|---|---|
| Mỹ và Canada (phần lớn) | Chủ Nhật thứ 2 của tháng 3 (2026-03-08) | Chủ Nhật đầu tiên của tháng 11 (2026-11-01) | 02:00 giờ địa phương |
| EU, Vương quốc Anh | Chủ Nhật cuối cùng của tháng 3 (2026-03-29) | Chủ Nhật cuối cùng của tháng 10 (2026-10-25) | 01:00 UTC, đổi đồng loạt |
| Úc (đông nam), New Zealand | Chủ Nhật đầu tiên của tháng 10 / Chủ Nhật cuối cùng của tháng 9 | Chủ Nhật đầu tiên của tháng 4 | Nam bán cầu: ngược mùa |
| Không có DST | Thổ Nhĩ Kỳ (UTC+3), Ấn Độ (+05:30), Trung Quốc, Nhật Bản, Singapore, UAE, phần lớn Châu Phi, Arizona | | |

Vì vậy, mỗi năm có hai giai đoạn "lệch pha" khi chênh lệch múi giờ giữa Mỹ và Châu Âu nhỏ hơn bình thường 1 tiếng: khoảng 3 tuần vào tháng 3 và 1 tuần vào cuối tháng 10 / đầu tháng 11. Các cuộc họp định kỳ được neo theo một khu vực sẽ bị dịch chuyển 1 tiếng đối với khu vực kia trong những tuần này.

## Các độ lệch múi giờ không tròn giờ
- Ấn Độ +05:30, Iran +03:30, Nepal +05:45, Newfoundland -03:30 (-02:30 vào mùa hè), Nam Úc +09:30/+10:30, Quần đảo Chatham +12:45. Một khung lưới tính theo giờ chẵn UTC sẽ rơi vào phút :30 hoặc :45 đối với những người này; script đánh giá theo từng bước 5 phút nên kết quả luôn chính xác tuyệt đối.

## Ngày cuối tuần và ngày làm việc
- Tuần làm việc từ Chủ Nhật đến Thứ Năm: ví dụ như Israel; nghỉ cuối tuần Thứ Sáu - Thứ Bảy ở một số quốc gia vùng Vịnh, trong khi UAE đã chuyển sang làm việc Thứ Hai - Thứ Sáu (với nửa ngày Thứ Sáu) vào năm 2022. Hãy hỏi rõ từng người.
- Ngày nghỉ lễ quốc gia khác nhau tùy theo từng nước và từng vùng (bang Bavaria so với Berlin, các tiểu bang của Mỹ). Hãy thêm chúng vào tệp thời gian bận (`blocked.csv`) theo ngày địa phương.
- Khung giờ 08:00 sáng Thứ Hai ở Los Angeles là tối Thứ Hai ở Ấn Độ nhưng đã là sáng Thứ Ba ở Sydney hoặc Auckland. Luôn hiển thị rõ thứ trong tuần cho từng người.

## DST ngay trong ngày chuyển đổi giờ
- Chỉnh đồng hồ tiến lên vào mùa xuân (Spring forward): khoảng thời gian địa phương từ 02:00 đến 03:00 (ở Mỹ) không tồn tại; chỉnh lùi lại vào mùa thu (Autumn back): khoảng 01:00-02:00 lặp lại hai lần. Tránh xếp lịch vào những giờ đó trong ngày đổi giờ; script hoạt động dựa trên các mốc thời gian UTC nên không bao giờ tạo ra thời gian không hợp lệ, nhưng con người khi đọc thấy "01:30" vào ngày hôm đó rất dễ bị nhầm lẫn.

## Lời mời lịch (Calendar invites) đi qua thời điểm đổi giờ
- Một lời mời họp định kỳ được neo vào múi giờ của người tổ chức. Cuộc họp hàng tuần lúc 10:00 sáng theo giờ New York sẽ giữ nguyên ở mức 10:00 tại New York và bị dịch chuyển giờ đối với Berlin trong các tuần lệch pha.
- Nếu chuỗi cuộc họp cần giữ sự công bằng cho cả hai phía, hãy chủ động neo lịch theo giờ UTC và thông báo cho mọi người biết, hoặc lên kế hoạch riêng cho các tuần lệch pha một cách rõ ràng.
- Kiểm tra lại múi giờ của lời mời sau khi tạo (một số công cụ mặc định lấy múi giờ của thiết bị hoặc giờ "trôi nổi" - floating time).
FILE:references/fairness-and-rotation.md
# Tính công bằng và cơ chế luân phiên

## Mô hình tính điểm bất tiện (Pain-point model) được script sử dụng
| Tình huống của một người trong một cuộc họp | Điểm |
|---|---|
| Hoàn toàn nằm trong khung giờ làm việc chuẩn của họ | 0 |
| Nằm một phần hoặc toàn bộ trong khung giờ dãn (stretch window - trước hoặc sau giờ làm, trong phạm vi họ đã đồng ý) | 2 điểm cho mỗi giờ dãn, tính theo tỷ lệ (30 phút = 1 điểm) |
| Nằm ngoài khung giờ dãn, rơi vào ngày nghỉ, hoặc trùng vào thời gian bận đã chặn | không thể tham dự (khung giờ bị loại nếu đây là người bắt buộc tham gia) |
| Người không bắt buộc (optional) không thể tham dự (chỉ tính vào bảng điểm luân phiên) | 3 |

Cách xếp hạng các khung giờ đơn lẻ: ít người không bắt buộc bị vắng mặt nhất, tiếp theo là điểm số của người chịu thiệt thòi nặng nhất ở mức thấp nhất, sau đó là tổng điểm thấp nhất.

Luân phiên (`--series N`): thuật toán tham lam (greedy) thêm từng cuộc họp một, tối đa một cuộc họp mỗi tuần chuẩn ISO (`--every week`, mặc định) hoặc mỗi ngày (`--every day`), ưu tiên chọn khung giờ giúp giữ tổng điểm tích lũy cao nhất ở mức thấp nhất, sau đó đến tổng bình phương các điểm tích lũy (giúp san sẻ sự bất tiện thay vì dồn vào một người), rồi đến số người vắng mặt ít hơn. Bảng tóm tắt hiển thị cho từng người: số buổi họp trong giờ làm việc, số buổi phải dãn giờ, số buổi vắng mặt và tổng điểm.

Trọng số chỉ là một mô hình ước lượng, không phải chân lý tuyệt đối. Hãy điều chỉnh chúng cùng với nhóm: việc bắt đầu họp sớm có thể gây khó khăn cho người có con nhỏ nhiều hơn là họp kết thúc muộn, một cuộc gọi lúc 22:00 đêm gây mệt mỏi hơn nhiều so với 18:30 tối. Hãy thay đổi khung giờ dãn (stretch window) cho từng người để phản ánh đúng mức độ họ thực sự chấp nhận được.

## Làm rõ các sự đánh đổi
- Hiển thị giờ địa phương và thứ trong tuần của từng người đối với mỗi phương án.
- Hiển thị bảng thống kê luân phiên, để việc "Bilal phải dãn giờ 3 trên 4 lần" là một quyết định nhìn thấy rõ ràng chứ không phải sự vô tình.
- Yêu cầu mọi người tự đặt khung giờ dãn của riêng mình; đừng bao giờ mặc định rằng ai đó sẽ nhận cuộc gọi lúc 06:30 sáng chỉ vì họ đã từng làm vậy một lần.

## Khi không tồn tại khung giờ công bằng nào
- Rút ngắn thời lượng cuộc họp: cuộc họp 30 phút thường dễ xếp vừa khung giờ hơn nhiều so với 60 phút.
- Chuyển một số người sang diện không bắt buộc (optional) và ghi hình lại cuộc họp kèm theo biên bản ghi chú và nhật ký quyết định (decision log).
- Tách cuộc họp theo khu vực (Châu Mỹ + Châu Âu, Châu Âu + Châu Á) với một người đóng vai trò cầu nối, và luân phiên người làm cầu nối này.
- Luân phiên hai khung giờ cố định theo từng tuần (ví dụ: 14:00 UTC và 07:00 UTC) để mỗi bên đều được hưởng khung giờ thuận tiện trong một nửa thời gian.
- Chuyển sang làm việc bất đồng bộ (async): cập nhật bằng văn bản, quay video demo sẵn, đưa ra quyết định trong tài liệu chung kèm hạn chót (deadline) tính theo giờ địa phương của mọi người.

## Danh sách kiểm tra (Checklist) trước khi gửi lời mời
- Mọi thành viên bắt buộc đều nằm trong giờ làm việc hoặc trong khung giờ dãn đã đồng ý đối với tất cả các buổi họp trong chuỗi.
- Ngày nghỉ lễ và ngày nghỉ phép ở từng quốc gia đã được đưa vào tệp chặn lịch (`blocked.csv`).
- Các đợt thay đổi giờ mùa hè (DST) trong khoảng thời gian đã được nêu tên rõ ràng và chuỗi lịch họp đã tính đến chúng.
- Lời mời được tạo ở múi giờ neo đã thống nhất; thời gian trong tin nhắn thông báo được ghi rõ theo giờ của từng người.
- Có ấn định ngày rà soát lại (ví dụ: sau đợt đổi giờ DST tiếp theo) để tiếp tục điều chỉnh luân phiên.
FILE:templates/slot-proposal.md
# Đề xuất thời gian họp: <tên cuộc họp>

**Thành phần:** <Ana (Los Angeles), Dana (New York), Eva (Berlin), Bilal (Istanbul), Priya (Kolkata, không bắt buộc)>
**Thời lượng / tần suất:** <60 phút hàng tuần, Thứ Ba - Thứ Năm>   **Khoảng thời gian đã kiểm tra:** <2026-10-26 đến 2026-11-20>
**Múi giờ neo cho lời mời lịch:** <UTC / múi giờ của người tổ chức>

## Khuyến nghị
| Tuần | Bắt đầu (UTC) | Ana | Dana | Eva | Bilal | Priya |
|---|---|---|---|---|---|---|
| <26 Th10> | <Thứ Ba 13:30> | <Thứ Ba 06:30 (dãn giờ)> | <09:30> | <14:30> | <16:30> | <19:00 (dãn giờ)> |
| <2 Th11> | <Thứ Tư 16:00> | <08:00> | <11:00> | <17:00 (dãn giờ)> | <19:00 (dãn giờ)> | <không thể tham gia (21:30)> |

## Giờ mùa hè (Daylight saving)
- <Châu Âu kết thúc giờ mùa hè vào 25 Th10, Mỹ vào 1 Th11: trong tuần lễ từ 26 Th10 New York chậm hơn Berlin 5 tiếng thay vì 6 tiếng, do đó khung giờ họp dịch chuyển 1 tiếng kể từ ngày 2 Th11>

## Mức độ công bằng trong toàn bộ chuỗi họp
| Tên | Trong giờ chuẩn | Dãn giờ | Vắng mặt | Điểm |
|---|---|---|---|---|
| <Ana> | <2> | <2> | <0> | <4> |

<Một câu nhận xét về việc ai đang phải gánh nhiều sự bất tiện nhất và giải pháp bù đắp của chúng ta (ví dụ: Priya: gửi bản ghi tóm tắt + tổ chức một khung giờ thân thiện với Châu Á mỗi tháng một lần).>

## Các phương án thay thế
1. <Phiên bản họp 30 phút lúc 15:00 UTC: tất cả mọi người đều trong giờ làm việc ngoại trừ ...>
2. <Luân phiên hai khung giờ cố định theo từng tuần>

## Các ngày bận đã được tính đến
- <29 Th10: Ngày Quốc khánh, Thổ Nhĩ Kỳ (Bilal)>

## Vui lòng xác nhận
- <khung giờ dãn (stretch window) của bạn vẫn chính xác>
- <các ngày nghỉ lễ hoặc nghỉ phép trong khoảng thời gian này mà chúng tôi còn bỏ sót>
FILE:examples/team.csv
name,zone,work_start,work_end,days,flex_before,flex_after,required
Ana,America/Los_Angeles,08:00,16:30,Mon-Fri,1.5,1,yes
Dana,America/New_York,08:30,17:00,Mon-Fri,1,1,yes
Eva,Europe/Berlin,09:00,17:30,Mon-Fri,1,1.5,yes
Bilal,Europe/Istanbul,09:00,18:00,Mon-Fri,0,2,yes
Priya,Asia/Kolkata,10:00,18:30,Mon-Fri,0,2,no
FILE:examples/blocked.csv
name,date,start,end,reason
Bilal,2026-10-29,,,Republic Day (public holiday in Turkey)
Dana,2026-11-03,10:00,11:30,dentist
Eva,2026-11-05,15:30,17:30,quarterly board review
Priya,2026-11-06,,,leave
FILE:scripts/slot_finder.py
#!/usr/bin/env python3
"""Time zone meeting slot finder (Python 3.9+ standard library: zoneinfo).

Given participants with IANA time zones, working days and hours (plus how far each one
is willing to stretch before or after work), finds meeting slots over a date range,
ranks them by fairness, and can pick a rotating series so the same people do not take
the early or late call every time. Each slot is evaluated in every participant's own
local time on that date, so daylight-saving transitions inside the range (Europe and
the US switch on different weekends) are handled automatically and reported.

Pain points per participant and meeting: 0 inside working hours, 2 per hour inside the
stretch ("flex") window before or after work, unavailable beyond that, on non-working
days or during blocked time. Missing an optional participant counts as 3 pain points
for them in the rotation tally.

Usage:
  python3 slot_finder.py --team team.csv --start 2026-10-26 --end 2026-11-06
        [--duration 60] [--step 30] [--blocked blocked.csv] [--days Tue,Wed,Thu]
        [--ref-zone UTC] [--top 10] [--series N [--every week|day]] [--json]
  python3 slot_finder.py --self-test
team.csv columns: name, zone, work_start, work_end, days (Mon-Fri or Sun-Thu or
Mon,Wed,Fri), flex_before, flex_after (hours, optional), required (yes/no, optional).
work_end earlier than work_start means a shift that runs past midnight.
blocked.csv columns: name, date (participant's local date), start, end (empty = all day), reason.
Exit codes: 0 = slots found, 1 = no slot where all required participants can attend,
2 = bad input (unknown time zone, bad time, missing file or column).
"""
import argparse, csv, datetime as dt, io, json, sys
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
CHUNK = 5  # minutes; fine enough for +05:30 and +05:45 zones
FLEX_PAIN_PER_HOUR = 2
MISSED_PAIN = 3


class InputError(Exception):
    pass


def parse_days(s):
    s = (s or "Mon-Fri").strip()
    out = set()
    for part in s.replace(" ", "").split(","):
        if not part:
            continue
        if "-" in part:
            a, b = part.split("-", 1)
            i, j = day_index(a), day_index(b)
            k = i
            while True:
                out.add(k)
                if k == j:
                    break
                k = (k + 1) % 7
        else:
            out.add(day_index(part))
    return out


def day_index(name):
    n = name.strip()[:3].title()
    if n not in DAY_NAMES:
        raise InputError(f"unknown day '{name}' (use Mon..Sun)")
    return DAY_NAMES.index(n)


def parse_hm(s, what):
    try:
        h, m = s.strip().split(":")
        h, m = int(h), int(m)
        if not (0 <= h <= 24 and 0 <= m < 60) or (h == 24 and m):
            raise ValueError
        return h * 60 + m
    except (ValueError, AttributeError):
        raise InputError(f"bad time '{s}' for {what} (use HH:MM, 24-hour)")


def load_zone(name, who, warnings):
    name = (name or "").strip()
    try:
        z = ZoneInfo(name)
    except (ZoneInfoNotFoundError, ValueError):
        raise InputError(f"unknown time zone '{name}' for {who}: use an IANA name such as America/New_York, Europe/Berlin, Asia/Kolkata (pip install tzdata if the system has no zone database)")
    if "/" not in name and name not in ("UTC", "Etc/UTC"):
        warnings.append(f"{who}: '{name}' is an abbreviation or legacy zone with fixed rules; use the city-based IANA name (e.g. America/New_York, not EST) so daylight saving is right")
    return z


def read_team(text, warnings):
    rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
    if not rows:
        raise InputError("team file has no rows")
    for col in ("name", "zone", "work_start", "work_end"):
        if col not in rows[0]:
            raise InputError(f"team file is missing column '{col}'")
    team = []
    for r in rows:
        who = (r.get("name") or "").strip()
        if not who:
            continue
        p = {"name": who, "zone_name": r["zone"].strip(), "zone": load_zone(r["zone"], who, warnings),
             "start": parse_hm(r["work_start"], who + " work_start"), "end": parse_hm(r["work_end"], who + " work_end"),
             "days": parse_days(r.get("days")),
             "flex_before": int(round(float(r.get("flex_before") or 0) * 60)),
             "flex_after": int(round(float(r.get("flex_after") or 0) * 60)),
             "required": (r.get("required") or "yes").strip().lower() not in ("no", "n", "false", "0", "optional"),
             "blocked": []}
        if p["start"] == p["end"]:
            raise InputError(f"{who}: work_start equals work_end")
        team.append(p)
    names = [p["name"] for p in team]
    if len(set(names)) != len(names):
        raise InputError("duplicate participant names in team file")
    return team


def read_blocked(text, team):
    byname = {p["name"].lower(): p for p in team}
    for r in csv.DictReader(io.StringIO(text.lstrip("\ufeff"))):
        who = (r.get("name") or "").strip()
        if not who:
            continue
        p = byname.get(who.lower())
        if not p:
            raise InputError(f"blocked time for unknown participant '{who}'")
        try:
            d = dt.date.fromisoformat(r["date"].strip())
        except (ValueError, KeyError):
            raise InputError(f"bad date in blocked file for {who}: {r.get('date')}")
        s, e = (r.get("start") or "").strip(), (r.get("end") or "").strip()
        a = dt.datetime.combine(d, dt.time()) + dt.timedelta(minutes=parse_hm(s, who + " blocked start") if s else 0)
        b = dt.datetime.combine(d, dt.time()) + dt.timedelta(minutes=parse_hm(e, who + " blocked end") if e else 1440)
        p["blocked"].append((a, b, (r.get("reason") or "").strip()))


def classify(p, local):
    """local: naive local datetime. Return 'core', 'flex' or None (unavailable)."""
    best = None
    for back in (0, 1):
        d = local.date() - dt.timedelta(days=back)
        if d.weekday() not in p["days"]:
            continue
        base = dt.datetime.combine(d, dt.time())
        cs = base + dt.timedelta(minutes=p["start"])
        ce = base + dt.timedelta(minutes=p["end"] + (1440 if p["end"] <= p["start"] else 0))
        if cs <= local < ce:
            return "core"
        if cs - dt.timedelta(minutes=p["flex_before"]) <= local < ce + dt.timedelta(minutes=p["flex_after"]):
            best = "flex"
    return best


def evaluate(p, start_utc, minutes):
    """Return (status, pain, local_start, local_end, reason). status in core, flex, out."""
    flex_min = 0
    reason = ""
    for k in range(0, minutes, CHUNK):
        t = (start_utc + dt.timedelta(minutes=k)).astimezone(p["zone"]).replace(tzinfo=None)
        c = classify(p, t)
        if c is None:
            reason = "outside hours" if t.date().weekday() in p["days"] or classify_day_any(p, t) else "day off"
            return "out", 0.0, *local_span(p, start_utc, minutes), reason
        for a, b, why in p["blocked"]:
            if a <= t < b:
                return "out", 0.0, *local_span(p, start_utc, minutes), f"blocked: {why}" if why else "blocked"
        if c == "flex":
            flex_min += CHUNK
    pain = round(flex_min / 60 * FLEX_PAIN_PER_HOUR, 2)
    return ("flex" if flex_min else "core"), pain, *local_span(p, start_utc, minutes), ""


def classify_day_any(p, t):
    return (t.date() - dt.timedelta(days=1)).weekday() in p["days"] and p["end"] <= p["start"]


def local_span(p, start_utc, minutes):
    a = start_utc.astimezone(p["zone"])
    b = (start_utc + dt.timedelta(minutes=minutes)).astimezone(p["zone"])
    return a, b


def dst_changes(team, start, end):
    out = []
    seen = set()
    for p in team:
        if p["zone_name"] in seen:
            continue
        seen.add(p["zone_name"])
        z = p["zone"]
        t = dt.datetime.combine(start - dt.timedelta(days=1), dt.time(), tzinfo=dt.timezone.utc)
        stop = dt.datetime.combine(end + dt.timedelta(days=2), dt.time(), tzinfo=dt.timezone.utc)
        prev = t.astimezone(z).utcoffset()
        while t < stop:
            t2 = t + dt.timedelta(minutes=30)
            off = t2.astimezone(z).utcoffset()
            if off != prev:
                loc = t2.astimezone(z)
                out.append({"zone": p["zone_name"], "utc": t2.strftime("%Y-%m-%d %H:%M UTC"),
                            "local": loc.strftime("%Y-%m-%d %H:%M %Z"), "from": fmt_off(prev), "to": fmt_off(off)})
                prev = off
            t = t2
    return out


def fmt_off(td):
    m = int(td.total_seconds() // 60)
    s = "+" if m >= 0 else "-"
    m = abs(m)
    return f"UTC{s}{m // 60:02d}:{m % 60:02d}"


def find_slots(team, start, end, duration, step, ref_zone, only_days=None):
    """start/end: dates in ref_zone (inclusive). Returns list of slot dicts."""
    slots = []
    t = dt.datetime.combine(start, dt.time(), tzinfo=ref_zone).astimezone(dt.timezone.utc)
    stop = dt.datetime.combine(end + dt.timedelta(days=1), dt.time(), tzinfo=ref_zone).astimezone(dt.timezone.utc)
    t = t.replace(minute=(t.minute // step) * step if step < 60 else 0, second=0, microsecond=0)
    while t + dt.timedelta(minutes=duration) <= stop:
        ref_local = t.astimezone(ref_zone)
        if only_days is None or ref_local.weekday() in only_days:
            per, ok = {}, True
            for p in team:
                st, pain, a, b, why = evaluate(p, t, duration)
                per[p["name"]] = {"status": st, "pain": pain, "local_start": a, "local_end": b, "reason": why}
                if st == "out" and p["required"]:
                    ok = False
                    break
            if ok:
                missing = [p["name"] for p in team if per[p["name"]]["status"] == "out"]
                pains = [per[p["name"]]["pain"] for p in team]
                slots.append({"utc": t, "per": per, "missing": missing, "total_pain": round(sum(pains), 2),
                              "max_pain": max(pains)})
        t += dt.timedelta(minutes=step)
    return slots


def rank(slots):
    return sorted(slots, key=lambda s: (len(s["missing"]), s["max_pain"], s["total_pain"], s["utc"]))


def period_key(s, ref_zone, every):
    d = s["utc"].astimezone(ref_zone).date()
    return d.isocalendar()[:2] if every == "week" else d


def pick_series(slots, team, n, ref_zone, every="week"):
    """Greedy fair rotation: n slots, at most one per ref-zone ISO week (every="week") or
    per date (every="day"), minimising the worst cumulative pain, then the sum of squared
    cumulative pain, then missed people."""
    cum = {p["name"]: 0.0 for p in team}
    chosen, used_dates = [], set()
    for _ in range(n):
        best, best_key = None, None
        for s in slots:
            d = period_key(s, ref_zone, every)
            if d in used_dates:
                continue
            new = {k: cum[k] + (MISSED_PAIN if s["per"][k]["status"] == "out" else s["per"][k]["pain"]) for k in cum}
            key = (round(max(new.values()), 2), round(sum(v * v for v in new.values()), 2), len(s["missing"]), s["total_pain"], s["utc"])
            if best_key is None or key < best_key:
                best, best_key = s, key
        if best is None:
            break
        chosen.append(best)
        used_dates.add(period_key(best, ref_zone, every))
        for k in cum:
            cum[k] += MISSED_PAIN if best["per"][k]["status"] == "out" else best["per"][k]["pain"]
    chosen.sort(key=lambda s: s["utc"])
    return chosen, cum


def cell(info):
    a, b = info["local_start"], info["local_end"]
    txt = f"{DAY_NAMES[a.weekday()]} {a:%H:%M}-{b:%H:%M}"
    if info["status"] == "flex":
        txt += f" (stretch, {info['pain']:g})"
    elif info["status"] == "out":
        txt += f" (cannot: {info['reason']})"
    return txt


def table(slots, team, ref_zone):
    head = ["#", f"Start ({ref_zone.key})"] + [f"{p['name']} ({p['zone_name']})" for p in team] + ["Pain total/max", "Missing"]
    out = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
    for i, s in enumerate(slots, 1):
        r = s["utc"].astimezone(ref_zone)
        row = [str(i), f"{DAY_NAMES[r.weekday()]} {r:%Y-%m-%d %H:%M}"] + [cell(s["per"][p["name"]]) for p in team] + \
              [f"{s['total_pain']:g}/{s['max_pain']:g}", ", ".join(s["missing"]) or "-"]
        out.append("| " + " | ".join(row) + " |")
    return out


def report(team, slots, args, ref_zone, warnings, changes, series=None, cum=None):
    out = [f"# Meeting slots {args['start']} to {args['end']} ({args['duration']} min, dates in {ref_zone.key})", ""]
    for w in warnings:
        out.append(f"> WARNING: {w}")
    if warnings:
        out.append("")
    out.append("## Participants")
    out.append("| Name | Zone | Hours (local) | Days | Stretch before/after | Required |")
    out.append("|---|---|---|---|---|---|")
    for p in team:
        days = ",".join(DAY_NAMES[d] for d in sorted(p["days"]))
        out.append(f"| {p['name']} | {p['zone_name']} | {p['start'] // 60:02d}:{p['start'] % 60:02d}-{p['end'] // 60:02d}:{p['end'] % 60:02d} | {days} | {p['flex_before'] / 60:g}h / {p['flex_after'] / 60:g}h | {'yes' if p['required'] else 'no'} |")
    out += ["", "## Daylight saving changes in range"]
    if changes:
        for c in changes:
            out.append(f"- {c['zone']}: {c['from']} -> {c['to']} at {c['local']} ({c['utc']}); the same UTC time moves by an hour locally after this")
    else:
        out.append("- none")
    out.append("")
    if not slots:
        out.append("No slot found where every required participant is available. Options: allow more stretch hours, make someone optional, shorten the meeting, split into two regional meetings, or go async.")
        return "\n".join(out)
    if series:
        out += [f"## Rotating series ({len(series)} meetings, fairest combination found by greedy rotation)", ""] + table(series, team, ref_zone)
        out += ["", "| Name | In hours | Stretch | Missed | Pain total |", "|---|---|---|---|---|"]
        for p in team:
            st = [s["per"][p["name"]]["status"] for s in series]
            out.append(f"| {p['name']} | {st.count('core')} | {st.count('flex')} | {st.count('out')} | {cum[p['name']]:g} |")
        out.append("")
    top = rank(slots)[:args["top"]]
    out += [f"## Best {len(top)} of {len(slots)} possible slots", ""] + table(top, team, ref_zone)
    out += ["", f"Pain: 0 inside working hours, {FLEX_PAIN_PER_HOUR} per hour of stretch, {MISSED_PAIN} for an optional person who cannot attend (rotation only)."]
    return "\n".join(out)


def to_json(team, slots, series, cum, changes, warnings, ref_zone, top):
    def enc(s):
        return {"start_utc": s["utc"].strftime("%Y-%m-%dT%H:%MZ"), "start_ref": s["utc"].astimezone(ref_zone).isoformat(),
                "total_pain": s["total_pain"], "max_pain": s["max_pain"], "missing": s["missing"],
                "participants": {k: {"status": v["status"], "pain": v["pain"], "local_start": v["local_start"].isoformat(),
                                     "local_end": v["local_end"].isoformat(), "reason": v["reason"]} for k, v in s["per"].items()}}
    return {"warnings": warnings, "dst_changes": changes, "slot_count": len(slots),
            "best": [enc(s) for s in rank(slots)[:top]], "series": [enc(s) for s in series or []], "series_pain": cum or {}}


def run(team_text, start, end, duration=60, step=30, blocked_text=None, ref="UTC", top=10, series=0, only_days=None, every="week"):
    warnings = []
    team = read_team(team_text, warnings)
    if blocked_text:
        read_blocked(blocked_text, team)
    ref_zone = load_zone(ref, "--ref-zone", warnings)
    if end < start:
        raise InputError("--end is before --start")
    if duration % CHUNK or step % CHUNK or duration <= 0 or step <= 0:
        raise InputError(f"--duration and --step must be positive multiples of {CHUNK} minutes")
    slots = find_slots(team, start, end, duration, step, ref_zone, only_days)
    chosen, cum = pick_series(slots, team, series, ref_zone, every) if series else (None, None)
    changes = dst_changes(team, start, end)
    return team, slots, chosen, cum, changes, warnings, ref_zone


def self_test():
    T = "name,zone,work_start,work_end,days,flex_before,flex_after,required\n"
    D = dt.date
    passed, total = 0, 0

    def check(name, cond):
        nonlocal passed, total
        total += 1
        passed += bool(cond)
        print(("PASS " if cond else "FAIL ") + name)

    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    check("Berlin+London overlap is 09:00-16:00 UTC (13 one-hour starts on the 30-min grid)", len(slots) == 13 and slots[0]["utc"].hour == 9 and slots[-1]["utc"].hour == 15)
    check("all in-hours slots have zero pain", all(s["total_pain"] == 0 for s in slots))
    team, slots, *_ = run(T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,0,0,yes\nB,Asia/Kolkata,09:00,18:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 6))
    check("LA and Kolkata without stretch: no slot", not slots)
    team, slots, *_ = run(T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,2,0,yes\nB,Asia/Kolkata,09:00,18:00,Mon-Fri,0,3,yes\n", D(2026, 11, 2), D(2026, 11, 6), duration=30)
    check("with 2h/3h stretch: only 30-min slots (15:00 UTC) exist and all carry pain", slots and all(s["total_pain"] > 0 for s in slots))
    s0 = rank(slots)[0]
    check("half-hour zone: Kolkata local minutes end in :30", s0["per"]["B"]["local_start"].minute == 30)
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 10, 26), D(2026, 10, 26))
    first = slots[0]["utc"]
    check("Berlin-NY in the gap week (EU back on CET, US still EDT): overlap 13:00-16:00 UTC", first.hour == 13 and slots[-1]["utc"].hour == 15)
    team, slots2, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    check("after US DST end: overlap starts 14:00 UTC", slots2[0]["utc"].hour == 14)
    *_, changes, w, rz = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,America/New_York,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 10, 24), D(2026, 11, 3))
    zones = {c["zone"]: c for c in changes}
    check("DST changes found: Berlin 2026-10-25 and New York 2026-11-01", "2026-10-25" in zones.get("Europe/Berlin", {}).get("local", "") and "2026-11-01" in zones.get("America/New_York", {}).get("local", ""))
    team, slots, *_ = run(T + "A,Asia/Dubai,09:00,17:00,Sun-Thu,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 6), D(2026, 11, 8))
    check("Sun-Thu vs Mon-Fri: no Fri/Sat/Sun slot", not slots)
    team, slots, *_ = run(T + "A,Asia/Dubai,09:00,17:00,Sun-Thu,0,0,yes\nB,Europe/London,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 9), D(2026, 11, 9))
    check("Sun-Thu vs Mon-Fri: Monday works", bool(slots))
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/Paris,09:00,17:00,Mon-Fri,0,0,no\n", D(2026, 11, 2), D(2026, 11, 2),
                          blocked_text="name,date,start,end,reason\nB,2026-11-02,,,holiday\n")
    check("optional person blocked all day: slots still found, B missing", slots and all(s["missing"] == ["B"] for s in slots))
    check("blocked reason shown", slots and "holiday" in slots[0]["per"]["B"]["reason"])
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\nB,Europe/Paris,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2),
                          blocked_text="name,date,start,end,reason\nB,2026-11-02,10:00,12:00,dentist\n")
    check("required person blocked 10-12 local: no slot overlaps 09:00-11:00 UTC", slots and not any(dt.time(8, 30) < s["utc"].time() < dt.time(11, 0) for s in slots))
    team, slots, *_ = run(T + "N,Europe/Berlin,22:00,06:00,Mon-Fri,0,0,yes\n", D(2026, 11, 3), D(2026, 11, 3))
    hours = sorted({s["utc"].hour for s in slots})
    check("overnight shift 22:00-06:00 Berlin (UTC+1): starts 00:00-04:00 and 21:00-23:00 UTC", hours == [0, 1, 2, 3, 4, 21, 22, 23])
    w = []
    load_zone("EST", "X", w)
    check("abbreviation zone gives a warning", w and "abbreviation" in w[0])
    try:
        load_zone("Mars/Olympus", "X", [])
        ok = False
    except InputError as e:
        ok = "unknown time zone" in str(e)
    check("unknown zone is an input error", ok)
    try:
        parse_hm("9am", "x")
        ok = False
    except InputError:
        ok = True
    check("bad time is an input error", ok)
    check("day ranges wrap: Fri-Mon", parse_days("Fri-Mon") == {4, 5, 6, 0})
    team3 = T + "A,America/Los_Angeles,09:00,17:00,Mon-Fri,2,0,yes\nB,Europe/Istanbul,09:00,18:00,Mon-Fri,0,2,yes\n"
    team, slots, chosen, cum, *_ = run(team3, D(2026, 11, 2), D(2026, 11, 6), series=4, every="day")
    st = {k: sum(1 for s in chosen if s["per"][k]["status"] == "flex") for k in ("A", "B")}
    check("series of 4 on 4 different dates", len(chosen) == 4 and len({s["utc"].date() for s in chosen}) == 4)
    check("rotation shares the stretch: both A and B stretch at least once", st["A"] >= 1 and st["B"] >= 1)
    check("rotation pain is balanced (difference <= 2 points)", abs(cum["A"] - cum["B"]) <= 2)
    team, slots, chosen, cum, *_ = run(team3, D(2026, 10, 26), D(2026, 11, 20), series=4)
    check("weekly series: one meeting in each of 4 ISO weeks", len({s["utc"].isocalendar()[1] for s in chosen}) == 4)
    team, slots, *_ = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 6), only_days={1, 3})
    check("--days Tue,Thu filters candidate days", slots and {s["utc"].weekday() for s in slots} == {1, 3})
    team, slots, ch, cum, changes, w, rz = run(T + "A,Europe/Berlin,09:00,17:00,Mon-Fri,0,0,yes\n", D(2026, 11, 2), D(2026, 11, 2))
    text = report(team, slots, {"start": "2026-11-02", "end": "2026-11-02", "duration": 60, "top": 3}, rz, w, changes)
    check("report renders table", "| 1 | Mon 2026-11-02 08:00 |" in text and "Daylight saving" in text)
    j = to_json(team, slots, None, None, changes, w, rz, 3)
    check("json output has best slots", len(j["best"]) == 3 and j["best"][0]["start_utc"] == "2026-11-02T08:00Z")
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Find fair meeting slots across time zones")
    ap.add_argument("--team")
    ap.add_argument("--blocked")
    ap.add_argument("--start")
    ap.add_argument("--end")
    ap.add_argument("--duration", type=int, default=60)
    ap.add_argument("--step", type=int, default=30)
    ap.add_argument("--days", help="only these weekdays in --ref-zone, e.g. Tue,Wed,Thu")
    ap.add_argument("--ref-zone", default="UTC")
    ap.add_argument("--top", type=int, default=10)
    ap.add_argument("--series", type=int, default=0, help="pick N meetings with rotating inconvenience")
    ap.add_argument("--every", default="week", choices=["week", "day"], help="series spacing: one meeting per ISO week (default) or per day")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not (a.team and a.start and a.end):
        ap.print_usage(sys.stderr)
        print("error: --team, --start and --end are required", file=sys.stderr)
        return 2
    try:
        start, end = dt.date.fromisoformat(a.start), dt.date.fromisoformat(a.end)
        team_text = open(a.team, encoding="utf-8").read()
        blocked_text = open(a.blocked, encoding="utf-8").read() if a.blocked else None
        only = parse_days(a.days) if a.days else None
        team, slots, chosen, cum, changes, warnings, rz = run(team_text, start, end, a.duration, a.step, blocked_text, a.ref_zone, a.top, a.series, only, a.every)
    except (InputError, OSError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps(to_json(team, slots, chosen, cum, changes, warnings, rz, a.top), indent=2))
    else:
        print(report(team, slots, {"start": a.start, "end": a.end, "duration": a.duration, "top": a.top}, rz, warnings, changes, chosen, cum))
    return 0 if slots else 1


if __name__ == "__main__":
    sys.exit(main())
PythonMeetingsTime Management+2
F@f
0
Công Cụ Kiểm Tra Bản Ghi DNS Zone (Linter)
Kỹ năng

Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.

---
name: dns-zone-record-linter
description: Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---

# Công Cụ Kiểm Tra Bản Ghi DNS Zone (Linter)

Bạn rà soát các bản ghi DNS giống như cách một quản trị viên DNS cẩn trọng thực hiện trước khi chuyển đổi hệ thống hoặc đưa cấu hình email vào vận hành thực tế. Bạn tìm ra những sai sót âm thầm làm hỏng dịch vụ (bản ghi CNAME đặt ở tên miền gốc apex, bản ghi MX trỏ tới một CNAME, `mail.example.com` viết thiếu dấu chấm ở cuối, hai bản ghi SPF cùng tồn tại), giải thích hậu quả bằng ngôn từ dễ hiểu, và trả lại các bản ghi đã được chỉnh sửa chuẩn xác. Bạn làm việc ngoại tuyến dựa trên tệp đầu vào; tuyệt đối không bao giờ tuyên bố đã thực hiện truy vấn trực tiếp (live lookup) khi bạn không hề làm điều đó.

## Các tệp trong kỹ năng này

- `scripts/dns_lint.py` - phân tích cú pháp tệp BIND zone ($ORIGIN, $TTL, @, tên tương đối, SOA nhiều dòng, chuỗi TXT trong dấu ngoặc kép) hoặc tệp xuất CSV (name,type,value,ttl,priority) và báo cáo các lỗi phát hiện được kèm số dòng, sau đó liệt kê các đích trỏ nằm ngoài zone cần kiểm tra trực tiếp trên mạng (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/record-rules.md` - bộ quy tắc đằng sau mỗi mục kiểm tra kèm tham chiếu RFC: tính độc quyền của CNAME, đích trỏ của MX/NS, dấu chấm cuối, SPF, DMARC, DKIM, các giá trị TTL và SOA
- `references/finding-codes.md` - toàn bộ mã lỗi kèm mức độ nghiêm trọng, trường hợp nào có thể chấp nhận được, và cách khắc phục thông thường
- `templates/dns-review.md` - mẫu báo cáo đánh giá để gửi lại cho người dùng (tóm tắt, bản ghi đã sửa, các mục cần kiểm tra trực tiếp, lưu ý khi triển khai)
- `examples/example.com.zone` - một tệp zone được xuất ra trước khi chuyển nhà cung cấp DNS, cố tình cài sẵn các lỗi phổ biến
- `examples/example.com.fixed.zone` - tệp zone đã được sửa hoàn chỉnh (chạy kiểm tra sạch lỗi hoàn toàn)
- `examples/lint-report.md` - kết quả đầu ra thực tế của công cụ kiểm tra đối với tệp ví dụ bị lỗi

## Quy trình thực hiện

### 1. Thu thập các bản ghi và ngữ cảnh sử dụng
Yêu cầu người dùng cung cấp tệp zone (định dạng BIND, kết quả lệnh `dig AXFR` hoặc tệp xuất từ nhà cung cấp) hoặc tệp CSV (`name,type,value,ttl,priority`), tên zone, và mục đích sử dụng của tên miền đó: chỉ chạy website, có gửi email (dùng dịch vụ nào: Google Workspace, Microsoft 365, công cụ gửi bản tin newsletter, hệ thống hỗ trợ khách hàng helpdesk), hay hoàn toàn không dùng email. Nếu người dùng dán các bản ghi từ giao diện web của nhà cung cấp, hãy lưu ý rằng tên máy chủ (host name) ở đó thường không có dấu chấm ở cuối và các bản ghi NS/SOA thường do nhà cung cấp tự quản lý.

### 2. Chạy công cụ kiểm tra (linter)
```
python3 scripts/dns_lint.py examples/example.com.zone
python3 scripts/dns_lint.py records.csv --origin example.com
python3 scripts/dns_lint.py zone.db --origin example.com --min-severity MEDIUM --ignore CAA_MISSING --json
python3 scripts/dns_lint.py --self-test
```
Mã thoát 0 = không có phát hiện mức HIGH, 1 = có phát hiện mức HIGH, 2 = dữ liệu đầu vào không hợp lệ (dấu ngoặc đơn hoặc dấu ngoặc kép chưa đóng, chưa rõ tên zone). Nếu không thể chạy mã, hãy áp dụng thủ công các quy tắc trong `references/record-rules.md` theo đúng trình tự đó.

### 3. Đọc kết quả phát hiện
- **HIGH** làm hỏng quá trình phân giải tên miền hoặc gửi/nhận email ngay lập tức (xung đột CNAME, MX trỏ tới CNAME, thiếu dấu chấm ở cuối, tồn tại hai bản ghi SPF, dùng `+all`, đặt DMARC sai tên bản ghi, cú pháp SPF không hợp lệ, khóa DKIM yếu).
- **MEDIUM** là cấu hình sai hoặc tiềm ẩn rủi ro nhưng có thể vẫn hoạt động tạm thời (thiếu DMARC/DKIM/SPF, dùng `?all`, địa chỉ IP nội bộ, giá trị TTL quá cực đoan, đích trỏ lơ lửng không tồn tại, chỉ có duy nhất một máy chủ phân giải tên miền NS).
- **LOW** là các vấn đề về chuẩn hóa và dọn dẹp (bản ghi trùng lặp, `p=none`, khóa DKIM 1024-bit, không có bản ghi CAA, dùng kiểu bản ghi SPF cũ đã lỗi thời).
- Công cụ chỉ đếm các lượt truy vấn SPF ở cấp cao nhất; các chỉ thị `include` lồng nhau bên trong sẽ làm tăng thêm số lượt truy vấn thực tế. Công cụ không thể nhìn thấy các bản ghi nằm ở phía nhà cung cấp dịch vụ ngoài (như các selector DKIM do dịch vụ email tạo ra) hoặc các đích trỏ nằm ngoài phạm vi zone. Hãy nói rõ giới hạn này, đồng thời liệt kê các lệnh kiểm tra trực tiếp từ bảng "Out-of-zone targets" (`dig +short CNAME shop.example.com`, `dig TXT _dmarc.example.com`).

### 4. Bàn giao kết quả
Điền vào `templates/dns-review.md`: phần tóm tắt ngắn gọn, các bản ghi đã sửa theo đúng định dạng của người dùng (dòng cấu hình tệp zone hoặc các trường nhập liệu trên giao diện nhà cung cấp), những nội dung cần xác minh trực tiếp trên mạng, và trình tự triển khai an toàn: hạ thấp TTL trước một ngày, thực hiện thay đổi, kiểm tra xác nhận, sau đó nâng TTL trở lại; đừng quên tăng số serial của bản ghi SOA. Đối với các thay đổi liên quan đến email, hãy khuyến nghị cấu hình DMARC ở mức `p=none` kèm địa chỉ `rua` trước tiên, sau đó nâng lên `quarantine`, rồi mới đến `reject` một khi các báo cáo trả về đã hoàn toàn sạch lỗi.

## Ví dụ kết quả
Trên tệp `examples/example.com.zone`, công cụ báo cáo 19 phát hiện (7 mức HIGH, 8 mức MEDIUM, 4 mức LOW): website được chuyển đi bằng bản ghi `@ CNAME bakery-site.example.net.` (đặt CNAME ở ngay tên miền gốc apex, nằm cạnh SOA, NS, MX và TXT), `www` vừa có CNAME vừa có bản ghi TXT xác minh tên miền, `MX 10 mail.example.com` thiếu dấu chấm ở cuối nên bị hiểu thành `mail.example.com.example.com.`, MX dự phòng lại trỏ tới một bản ghi CNAME, có tới hai bản ghi `v=spf1` (một bản ghi dùng `?all`), DMARC đặt ở tên `dmarc` thay vì `_dmarc`, một selector DKIM cũ đã bị thu hồi khóa, địa chỉ liên hệ trong SOA chứa ký tự `@`, và bản ghi `intranet` công khai địa chỉ IP nội bộ 10.0.4.12. Tệp `examples/example.com.fixed.zone` đã khắc phục toàn bộ các lỗi trên và vượt qua bài kiểm tra hoàn toàn sạch sẽ (mã thoát 0).

## Quy tắc

- Không bao giờ tự bịa ra giá trị bản ghi (địa chỉ IP, khóa công khai DKIM, tên máy chủ của nhà cung cấp); hãy dùng các ký hiệu giữ chỗ (placeholder) và hướng dẫn rõ người dùng cần sao chép thông tin gì từ nhà cung cấp dịch vụ.
- Chỉ giữ duy nhất một bản ghi SPF cho mỗi tên miền/tên miền phụ; hãy gộp chung các chỉ thị `include` vào cùng một bản ghi thay vì tạo thêm bản ghi SPF thứ hai.
- Không đề xuất xóa các selector DKIM hoặc bản ghi MX có khả năng vẫn đang được sử dụng mà chưa hỏi xem những dịch vụ nào đang gửi email.
- Giải thích tác hại của việc thiếu dấu chấm ở cuối bằng cách viết rõ tên miền sau khi bị nối đuôi tự động, để người dùng nhìn thấy tận mắt lỗi sai đó.
- Đánh dấu bất kỳ mục nào cần truy vấn mạng thực tế là "cần xác minh trực tiếp" (to verify); tuyệt đối không trình bày như thể bạn đã kiểm tra rồi.
- Mọi thay đổi đối với bản ghi NS, MX hoặc các bản ghi ở tên miền gốc (apex) phải luôn đi kèm hướng dẫn triển khai và phương án hoàn tác (ghi lại giá trị cũ, thông số TTL, thời điểm cần kiểm tra).
FILE:references/record-rules.md
# Các quy tắc bản ghi DNS mà công cụ áp dụng

## Tên bản ghi và dấu chấm ở cuối (trailing dots)
- Trong tệp zone, một tên không có dấu chấm ở cuối được hiểu là tên tương đối: cả `www` và `mail.example.com` đều sẽ bị nối thêm hậu tố tên miền gốc ($ORIGIN) vào đuôi. Khi đó, `mail.example.com` nằm trong zone `example.com.` sẽ biến thành `mail.example.com.example.com.` - đây là lỗi sao chép và dán (copy-paste) phổ biến nhất. Hãy viết đầy đủ là `mail.example.com.` hoặc chỉ viết ngắn gọn là `mail`.
- Ký hiệu `@` đại diện cho tên miền gốc (origin / apex). Một dòng bắt đầu bằng khoảng trắng sẽ kế thừa lại tên chủ sở hữu (owner name) của dòng ngay phía trên nó.
- Biểu mẫu web của các nhà cung cấp DNS thường tự động xử lý dấu chấm cuối cho bạn; vì vậy công cụ sẽ bỏ qua bước kiểm tra dấu chấm cuối đối với dữ liệu đầu vào dạng CSV.

## CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- Một tên đã gắn bản ghi CNAME thì không được phép chứa bất kỳ dữ liệu bản ghi nào khác cùng tên (ngoại trừ các bản ghi DNSSEC). Các máy chủ phân giải (resolver) khi gặp CNAME sẽ chỉ trả về CNAME đó và phớt lờ toàn bộ phần còn lại, do đó nếu đặt bản ghi TXT hoặc MX cùng tên bên cạnh CNAME thì chúng sẽ bị vô hiệu hóa hoặc gây lỗi.
- Tên miền gốc (apex) luôn bắt buộc phải có bản ghi SOA và NS, vì vậy đặt bản ghi CNAME ở tên miền gốc không bao giờ hợp lệ theo chuẩn DNS. Hãy sử dụng bản ghi A/AAAA, hoặc tính năng ALIAS / ANAME / CNAME flattening của nhà cung cấp DNS (các tính năng này thực chất sẽ trả kết quả về dưới dạng bản ghi A/AAAA).
- Mỗi tên chỉ được phép có duy nhất một bản ghi CNAME. Việc nối chuỗi (CNAME trỏ sang một CNAME khác) vẫn hoạt động nhưng làm tốn thêm lượt truy vấn phân giải; hãy giữ chuỗi trỏ ngắn nhất có thể.
- Một bản ghi CNAME trỏ tới dịch vụ bên ngoài (trang bán hàng, trang trạng thái hệ thống, CDN) mà sau đó tài khoản dịch vụ đã bị hủy có thể bị kẻ khác đăng ký chiếm quyền điều khiển (tấn công chiếm quyền tên miền phụ - subdomain takeover). Hãy liệt kê các bản ghi dạng này và gỡ bỏ ngay những bản ghi không còn sử dụng.

## Đích trỏ của MX, NS, SRV (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- Bản ghi MX, NS và SRV bắt buộc phải trỏ tới một tên máy chủ (host) có bản ghi A/AAAA trực tiếp, không được trỏ tới một bản ghi CNAME và cũng không được điền trực tiếp địa chỉ IP.
- Giá trị độ ưu tiên (preference) của MX càng nhỏ thì độ ưu tiên càng cao. Các máy chủ MX dự phòng phải thực sự được cấu hình để nhận thư cho tên miền đó, nếu không chúng sẽ chỉ trở thành nơi thu hút thư rác (spam).
- Bản ghi Null MX `MX 0 .` tuyên bố rõ rằng tên miền này không nhận bất kỳ email nào. Nếu sử dụng, nó phải là bản ghi MX duy nhất của tên miền đó.
- Hãy sử dụng ít nhất hai máy chủ NS nằm trên các mạng độc lập nhau; danh sách máy chủ NS khai báo tại nhà đăng ký tên miền (registrar delegation) phải khớp với các bản ghi NS bên trong tệp zone.

## SPF (RFC 7208)
- Chỉ được phép có duy nhất một bản ghi TXT `v=spf1` cho mỗi tên. Có hai bản ghi SPF = lỗi `permerror` = kiểm tra SPF thất bại đối với toàn bộ email gửi đi.
- Các thành phần (terms): `ip4:`, `ip6:`, `a`, `mx`, `include:`, `exists:`, `ptr` (đã ngừng khuyến nghị sử dụng), `all`, cùng các biến điều chỉnh (modifiers) `redirect=` và `exp=`. Các tiền tố định tính (qualifiers): `+` cho phép / pass (mặc định), `-` từ chối cứng / fail, `~` từ chối mềm / softfail, `?` trung lập / neutral.
- Tối đa 10 thành phần làm phát sinh truy vấn DNS (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`), được tính tổng cộng xuyên suốt cả các chỉ thị `include` lồng bên trong. Các thành phần `ip4`/`ip6` không tốn lượt truy vấn DNS nào.
- Hãy kết thúc bản ghi bằng `-all` (hoặc `~all` trong giai đoạn thử nghiệm). Dùng `+all` đồng nghĩa với việc cho phép bất kỳ ai trên internet mạo danh bạn gửi thư; `?all` không mang lại tín hiệu bảo vệ nào. Mọi thành phần viết phía sau `all` đều bị bỏ qua.
- Kiểu bản ghi DNS mang tên SPF (type 99) đã bị khai tử; chỉ xuất bản SPF dưới dạng bản ghi TXT.
- Một chuỗi nằm trong cặp dấu ngoặc kép chỉ chứa được tối đa 255 byte; các bản ghi dài phải được tách thành nhiều chuỗi `"phần một" "phần hai"` và sẽ được hệ thống tự động nối liền lại với nhau (không tự thêm dấu cách ở giữa).

## DMARC (RFC 7489)
- Bản ghi TXT chỉ đặt tại `_dmarc.<domain>`. Phải bắt đầu bằng `v=DMARC1;` và bao gồm `p=none|quarantine|reject`. Chỉ có một bản ghi duy nhất.
- Thêm `rua=mailto:` để nhận báo cáo tổng hợp. Triển khai theo lộ trình `p=none` -> `quarantine` -> `reject`; giá trị `pct` dưới 100 sẽ áp dụng chính sách cho một phần lượng thư.
- Từ năm 2024, Gmail và Yahoo yêu cầu SPF, DKIM và DMARC (ít nhất là `p=none`) đối với những bên gửi thư số lượng lớn.

## DKIM (RFC 6376, RFC 8301, RFC 8463)
- Khóa được đặt tại `<selector>._domainkey.<domain>` dưới dạng TXT (`v=DKIM1; k=rsa; p=<base64>`) hoặc dưới dạng CNAME trỏ tới nhà cung cấp (Microsoft 365, nhiều ESP).
- Không được sử dụng khóa RSA dưới 1024 bit; mức khuyến nghị là 2048 bit. Có thể thêm Ed25519 (`k=ed25519`) song song với RSA.
- Giá trị `p=` để trống nghĩa là khóa đã bị thu hồi. Các selector không thể tự dò tìm được: việc không xuất hiện trong một tệp có nghĩa là "không có ở đây", chứ không phải "không có ở bất kỳ đâu".

## TTL và SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL nằm trong khoảng 0..2147483647 s. Các giá trị thường dùng: 300-3600 s cho các bản ghi hay thay đổi, 3600-86400 s cho các bản ghi ổn định. Dưới 60 s thì nhiều bộ phân giải (resolver) đằng nào cũng tự nâng lên; trên một tuần thì một sai sót sẽ tồn đọng suốt cả tuần. Hãy hạ thấp TTL trước một ngày khi có thay đổi theo kế hoạch.
- `$TTL` thiết lập giá trị mặc định. Trường minimum của SOA là TTL lưu đệm âm (negative-caching TTL - khoảng thời gian trạng thái "không tồn tại" được lưu vào bộ nhớ đệm): 300-3600 s.
- Địa chỉ liên hệ SOA (rname) là một email được viết bằng dấu chấm: `hostmaster.example.com.` thay cho hostmaster@example.com.
- Tăng số serial của SOA sau mỗi lần thay đổi (YYYYMMDDnn).

## Địa chỉ và CAA
- A chứa địa chỉ IPv4, AAAA chứa địa chỉ IPv6. Các địa chỉ nội bộ (private), loopback và link-local không thuộc về một vùng (zone) công khai (hãy dùng DNS phân tách tầm nhìn - split-horizon DNS). Các dải địa chỉ dành cho tài liệu (192.0.2.0/24, 2001:db8::/32) có thể dùng bình thường trong các ví dụ.
- CAA (RFC 8659) giới hạn những tổ chức cấp phát chứng chỉ (CA) nào được phép cấp chứng chỉ cho tên miền: `@ CAA 0 issue "letsencrypt.org"`.
FILE:references/finding-codes.md
# Mã lỗi phát hiện (Finding codes)

| Mã | Mức độ | Vấn đề | Chấp nhận được khi | Cách sửa thông thường |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | CNAME ở đỉnh vùng (apex) xung đột với SOA/NS, làm hỏng luồng thư | không bao giờ | dùng A/AAAA hoặc ALIAS/flattening của nhà cung cấp |
| CNAME_WITH_OTHER_DATA | HIGH | các bản ghi khác nằm cạnh CNAME bị bỏ qua | chỉ các bản ghi DNSSEC | chuyển TXT/MX đi nơi khác hoặc dùng A/AAAA |
| MULTIPLE_CNAME | HIGH | hai bản ghi CNAME cho cùng một tên | không bao giờ | giữ lại một bản ghi |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | đích của MX/NS/SRV là một bí danh (alias) | không bao giờ (một số máy chủ dung thứ, nhưng nhiều máy chủ thì không) | trỏ tới máy chủ A/AAAA cuối cùng |
| TARGET_IS_IP | HIGH | giá trị MX/NS/CNAME là một địa chỉ IP | không bao giờ | dùng tên máy chủ có bản ghi A |
| MISSING_TRAILING_DOT | HIGH | tên đầy đủ không có dấu chấm ở cuối sẽ bị nối thêm miền gốc (origin) | không bao giờ | thêm dấu chấm hoặc viết ở dạng tương đối |
| OWNER_DOUBLED_ORIGIN | HIGH | tên chủ sở hữu `www.example.com` không có dấu chấm | không bao giờ | `www` hoặc `www.example.com.` |
| SPF_MULTIPLE | HIGH | hai bản ghi v=spf1 -> lỗi permerror | không bao giờ | gộp thành một bản ghi |
| SPF_PASS_ALL | HIGH | `+all`/`all` cấp quyền cho tất cả mọi người | không bao giờ | `-all` / `~all` |
| SPF_SYNTAX | HIGH | thành phần không xác định hoặc ip4/ip6 sai định dạng -> permerror | không bao giờ | sửa lại thành phần đó |
| SPF_TOO_MANY_LOOKUPS | HIGH | nhiều hơn 10 thành phần truy vấn (lookup) | không bao giờ | làm phẳng (flatten), xóa các include không dùng |
| TXT_STRING_TOO_LONG | HIGH | chuỗi trong ngoặc kép dài quá 255 byte | giao diện nhà cung cấp tự tách giúp bạn (đầu vào CSV không được kiểm tra) | tách thành từng đoạn nhỏ |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 không nằm ở _dmarc | không bao giờ | chuyển sang `_dmarc` |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | bên nhận bỏ qua DMARC | không bao giờ | giữ một bản ghi hợp lệ duy nhất |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | không thể tìm thấy hoặc phân tích cú pháp khóa | không bao giờ | sao chép lại từ nhà cung cấp |
| DKIM_WEAK_KEY | HIGH | khóa RSA dưới 1024 bit | không bao giờ | xoay vòng khóa lên 2048 bit |
| SOA_RNAME_AT | HIGH | có ký tự `@` trong liên hệ SOA | không bao giờ | `hostmaster.example.com.` |
| NULL_MX_WITH_OTHERS | HIGH | có cả `MX 0 .` lẫn MX thực | không bao giờ | chỉ chọn một trong hai |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | không thể nạp giá trị | không bao giờ | sửa lại giá trị |
| SPF_MISSING | MEDIUM | có MX nhưng không có SPF | không bao giờ đối với tên miền gửi/nhận thư | thêm một bản ghi SPF |
| DMARC_MISSING | MEDIUM | tên miền thư không có DMARC | không bao giờ | `v=DMARC1; p=none; rua=...` sau đó siết chặt dần |
| DKIM_NOT_FOUND | MEDIUM | không có selector trong tệp này | selector được lưu trữ ở nơi khác (kiểm tra trực tiếp) | công bố selector của nhà cung cấp |
| DKIM_REVOKED | MEDIUM | `p=` để trống | selector được chủ đích ngừng sử dụng (sau đó hãy xóa đi) | xóa bản ghi hoặc công bố khóa mới |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | chính sách yếu hoặc bị bỏ qua | giai đoạn thử nghiệm ngắn | kết thúc bằng `-all` / `~all` |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | trông giống tên bên ngoài vùng nhưng thiếu dấu chấm | thực sự là một tên tương đối | thêm dấu chấm |
| DANGLING_TARGET | MEDIUM | đích trong vùng không có bản ghi nào | được định nghĩa ở tệp hoặc view khác | thêm bản ghi hoặc sửa lại |
| MX_TARGET_NO_ADDRESS | MEDIUM | máy chủ thư không có A/AAAA | địa chỉ đến từ một vùng khác | thêm A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | địa chỉ RFC 1918/loopback/link-local trong DNS công khai | vùng được chủ đích dùng riêng trong nội bộ | dùng DNS phân tách tầm nhìn (split-horizon) |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | dưới 60 s / trên 7 ngày | TTL ngắn trong đợt chuyển đổi theo kế hoạch; `_acme-challenge` | 300 s - 1 ngày |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | tệp vùng (zone file) chưa hoàn chỉnh | NS/SOA do nhà cung cấp quản lý (đầu vào CSV bỏ qua phần này) | bổ sung đầy đủ |
| UNKNOWN_TYPE | MEDIUM | loại bản ghi không được nhận diện | loại bản ghi mới mà kịch bản chưa biết | kiểm tra lỗi chính tả |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | đã lỗi thời hoặc gần chạm ngưỡng giới hạn | - | thay thế / đếm trực tiếp các include lồng nhau |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC chưa thực thi chặn hoặc chưa gửi báo cáo | trong quá trình triển khai ban đầu | siết chặt sau khi xem xét báo cáo |
| DKIM_1024_KEY | LOW | RSA 1024-bit | nhà cung cấp không hỗ trợ 2048 | xoay vòng khóa khi có thể |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | tên miền không dùng thư có thể bị giả mạo | - | null MX + `v=spf1 -all` + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | vệ sinh cấu hình (hygiene) | - | xem cột cách sửa trong báo cáo |

Mã thoát (Exit codes): 0 = không có lỗi HIGH, 1 = có lỗi HIGH, 2 = đầu vào không hợp lệ. Trong CI, hãy chặn ở mức HIGH và lưu danh sách `--ignore` bất kỳ trong kho mã nguồn kèm theo lý do.

## Các kiểm tra trực tiếp mà bộ linter không thể thực hiện (ngoại tuyến)
- Các đích nằm ngoài vùng: `dig +short A <target>`; một bản ghi CNAME trỏ tới máy chủ SaaS đã hủy đăng ký sẽ dẫn đến nguy cơ bị chiếm quyền điều khiển (subdomain takeover).
- Các include SPF lồng nhau: đếm đệ quy bằng `dig TXT <include>`, hoặc dùng công cụ kiểm tra SPF dựa trên bộ phân giải.
- Các selector DKIM do nhà cung cấp dịch vụ thư lưu trữ: `dig TXT <selector>._domainkey.<domain>` (Google: `google`, Microsoft 365: các bản ghi CNAME `selector1`/`selector2`).
- Ủy quyền (Delegation): `dig NS <domain> @a.gtld-servers.net` (hoặc máy chủ của TLD tương ứng) phải khớp với tập hợp NS của vùng.
FILE:templates/dns-review.md
# Đánh giá DNS: <domain>

**Nguồn:** <zone file / provider export, date>   **Mục đích:** <website + Google Workspace mail + newsletter tool>
**Kết quả:** <HIGH n, MEDIUM n, LOW n> - <nhận định trong một câu, ví dụ: "việc gửi nhận thư đang gặp rủi ro cho đến khi SPF được gộp lại và MX được sửa">

## Cần sửa ngay (HIGH)
| # | Bản ghi hiện tại | Vấn đề | Thay thế bằng |
|---|---|---|---|
| 1 | `<@ CNAME bakery-site.example.net.>` | <CNAME ở đỉnh vùng làm ẩn MX/TXT> | `<@ A 192.0.2.10>` (hoặc ALIAS tại nhà cung cấp) |
| 2 | `<@ MX 10 mail.example.com>` | <thiếu dấu chấm ở cuối -> mail.example.com.example.com.> | `<@ MX 10 mail.example.com.>` |

## Cần sửa sớm (MEDIUM)
- <thêm TXT cho `_dmarc` với giá trị `v=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>`>

## Vệ sinh cấu hình (LOW)
- <xóa bản ghi A trùng lặp của blog; thêm CAA>

## Tóm tắt xác thực thư điện tử
| Hạng mục | Hiện tại | Sau khi sửa |
|---|---|---|
| SPF | <2 bản ghi, ?all> | <1 bản ghi, bao gồm A + B, -all> |
| DKIM | <selector google 1024-bit, s2019 đã thu hồi> | <khóa 2048-bit, đã xóa selector ngừng sử dụng> |
| DMARC | <sai tên bản ghi> | <_dmarc p=none -> chuyển sang quarantine vào <date>> |

## Xác minh trực tiếp (chưa kiểm tra ngoại tuyến)
- `<dig +short CNAME shop.<domain>>` - <đích trỏ tới có còn được sử dụng không?>
- `<dig TXT google._domainkey.<domain>>` - <có khớp với bảng điều khiển của nhà cung cấp không?>

## Lộ trình triển khai (Rollout)
1. <hạ TTL của các bản ghi sắp thay đổi xuống 300 s, chờ hết một chu kỳ TTL cũ>
2. <áp dụng thay đổi, tăng số serial của SOA lên YYYYMMDDnn>
3. <xác minh bằng lệnh dig với máy chủ có thẩm quyền (authoritative servers) và bộ phân giải công khai; gửi một thư thử nghiệm và đọc tiêu đề Authentication-Results>
4. <nâng TTL trở lại 3600 s; xem xét các báo cáo DMARC sau 1-2 tuần>

**Hoàn tác (Rollback):** <các giá trị cũ được liệt kê ở trên; khôi phục lại chúng và chờ một chu kỳ TTL>
**Câu hỏi bỏ ngỏ:** <những dịch vụ nào gửi thư dưới danh nghĩa tên miền này? backupmx có còn được sử dụng không?>
FILE:examples/example.com.zone
; example.com - zone exported before the move to a new DNS host (has mistakes on purpose)
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster@example.com. (
                2026103001 ; serial
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                172800 )   ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.

; website moved to a static host
@       IN CNAME bakery-site.example.net.
www  30 IN CNAME bakery-site.example.net.
www     IN TXT  "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com
blog    IN A    192.0.2.44
blog    IN A    192.0.2.44
legacy 1209600 IN A 192.0.2.45
intranet IN A   10.0.4.12

; mail
@       IN MX 10 mail.example.com
@       IN MX 20 backupmx
backupmx IN CNAME mx2.example.net.
@       IN TXT  "v=spf1 include:_spf.google.com ~all"
@       IN TXT  "v=spf1 include:sendgrid.net ?all"
dmarc   IN TXT  "v=DMARC1; p=none"
google._domainkey IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbNfaf4mo/6FvrWAv3WssXhXwsdrJxrAuhIVcYXdrMZp55PDpEjo63C/vOuaStp58YtZTnzQlm0V3rjtvOP1XgGAy3LWurXdRkT0zOGxx0AMgjZfD/8QDgjF6f4VW9zmNytbD3EgZXxeGZSBw8JtTdT/rOEygTgYL5+x5DftDSFQIDAQAB"
s2019._domainkey  IN TXT "v=DKIM1; k=rsa; p="
FILE:examples/example.com.fixed.zone
; example.com - corrected zone
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster.example.com. (
                2026103002 ; serial (bumped)
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                3600 )     ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.
@       IN CAA  0 issue "letsencrypt.org"

; website: A/AAAA at the apex (or the provider's ALIAS/flattening), CNAME only below it
@       IN A    192.0.2.10
@       IN AAAA 2001:db8::10
www     IN CNAME bakery-site.example.net.
_site-verification.www IN TXT "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com.
blog    IN A    192.0.2.44
legacy  IN A    192.0.2.45
; intranet moved to the internal (split-horizon) view

; mail
@       IN MX 10 mail.example.com.
@       IN MX 20 mx2.example.net.
mail    IN A    192.0.2.25
@       IN TXT  "v=spf1 include:_spf.google.com include:sendgrid.net -all"
_dmarc  IN TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
google._domainkey IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7ouq6bH4GbHRPASRa3m/LiSvOKSZ7L7KZqE231EHY1s8FN9criBQkJXXmxkwPERQSVSyRIC4LjSpxwPTe6LEVhg7nrswTX1ktBbXMUmkgj8CPKV+yWzOsDOMk7kjqghuNWXD/R5K/mT7xbTiCaXztegoQi+"
                           "kVXf0xuMmQYLxVthdjIz1h2PORFhtjzwKYbpaMKjq2BWKIaNtaGBxc2kaSLyPw8W65+A7XeLhtKT8zcteU72IpdmibNsHlkBR0Hb7rQgb+0LgNVqPvF/mm37PKFPQPrfWjjL/Fc1MQoCm8jO3kI0O5ssuP7KKO7ZjtT4ekeHytw23INuPh/Kw1LqzwIDAQAB" )
FILE:examples/lint-report.md
# Báo cáo kiểm tra (lint) bản ghi DNS: example.com.zone

Vùng: example.com.   Định dạng: zone   Số bản ghi: 19   Phát hiện: 19 (HIGH 7, MEDIUM 8, LOW 4)

| Mức độ | Mã | Dòng | Tên | Loại | Vấn đề | Cách sửa |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | thông tin liên hệ 'hostmaster@example.com.' chứa ký tự '@'; trong DNS ký tự này phải được viết bằng dấu chấm | viết thông tin liên hệ là hostmaster.example.com. (ký tự @ đầu tiên đổi thành dấu chấm) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | giá trị minimum của SOA là 172800 s: một tên bị xóa rồi thêm lại sẽ bị lưu trong bộ nhớ đệm là 'không tồn tại' suốt khoảng thời gian đó | đặt giá trị minimum của SOA (TTL lưu đệm âm) thành 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME tại đỉnh vùng (zone apex) xung đột với các bản ghi SOA và NS tại đó; việc tra cứu thư và các truy vấn khác sẽ bị lỗi | sử dụng các bản ghi A/AAAA ở đỉnh vùng, hoặc bản ghi ALIAS/ANAME/CNAME-flattening của nhà cung cấp |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | tên này có cả CNAME lẫn TXT; các bộ phân giải sẽ chỉ trả về CNAME | một tên đã có CNAME thì không được phép có bản ghi nào khác: hãy chuyển các bản ghi khác đi nơi khác hoặc thay thế CNAME bằng A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: làm tăng tải truy vấn, tra cứu chậm hơn, và nhiều bộ phân giải đằng nào cũng tự nâng giá trị này lên | sử dụng 300 s trở lên cho các bản ghi thông thường (chỉ hạ thấp khi có thay đổi theo kế hoạch) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' trông giống một tên máy chủ đầy đủ nhưng không có dấu chấm ở cuối, nên sẽ bị hiểu thành shops.myshopify.com.example.com. | nếu đây là một tên máy chủ bên ngoài, hãy thêm dấu chấm ở cuối |
| LOW | DUPLICATE_RECORD | 19 | blog | A | giống hệt dòng 18 | xóa bản ghi bị trùng lặp |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 ngày): một giá trị sai sẽ bị lưu giữ trong bộ nhớ đệm suốt khoảng thời gian đó | sử dụng từ 1 giờ đến 1 ngày; nếu trên một tuần, các sai sót sẽ mất cả tuần để khắc phục |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 là địa chỉ nội bộ (private)/loopback; người dùng bên ngoài không thể truy cập và nó làm lộ cấu trúc mạng nội bộ | không công bố địa chỉ nội bộ/loopback trong một vùng công khai (hãy sử dụng DNS phân tách tầm nhìn - split-horizon DNS nếu cần) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' không có dấu chấm ở cuối, nên sẽ bị hiểu thành mail.example.com.example.com. | kết thúc các tên miền đủ điều kiện (FQDN) bằng một dấu chấm (mail.example.com.) hoặc viết ở dạng tương đối (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | đích MX mail không có bản ghi nào trong vùng này | thêm bản ghi trong vùng còn thiếu hoặc sửa lại tên đích |
| HIGH | MX_TO_CNAME | 25 | @ | MX | đích MX backupmx là một CNAME (không được phép theo RFC 2181 10.3) | trỏ MX vào một tên có các bản ghi A/AAAA (đích cuối cùng của CNAME) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | có 2 bản ghi v=spf1 tại tên này (dòng 27, 28) | gộp thành MỘT bản ghi v=spf1 duy nhất; việc có hai bản ghi sẽ làm mọi kiểm tra SPF bị lỗi (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' mang tính trung lập: những người gửi trái phép không bị đánh dấu | sử dụng ~all hoặc -all; ?all không cung cấp tín hiệu xử lý nào cho bên nhận |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | bản ghi DMARC không nằm tại _dmarc.<domain>, nên bên nhận không bao giờ nhìn thấy nó | DMARC chỉ được đọc từ _dmarc.<domain>; hãy chuyển bản ghi về đó |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | khóa RSA có độ dài 1024 bit; mức khuyến nghị hiện nay là 2048 | lên kế hoạch xoay vòng sang khóa 2048-bit |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= để trống: selector này đã bị thu hồi và thư được ký bằng nó sẽ trượt kiểm tra DKIM | giá trị p= rỗng có tác dụng thu hồi khóa; hãy xóa bản ghi nếu selector đã ngừng sử dụng, hoặc công bố khóa mới |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | không có bản ghi DMARC: bên nhận không thể phân biệt thư giả mạo với thư thật của bạn, đồng thời Gmail/Yahoo yêu cầu DMARC đối với người gửi số lượng lớn | thêm bản ghi TXT tại _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@<domain>" sau đó chuyển dần sang quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | không có bản ghi CAA: bất kỳ CA công khai nào cũng có thể cấp chứng chỉ cho tên miền này | tăng cường bảo mật tùy chọn: CAA 0 issue "<CA của bạn>" giúp giới hạn tổ chức nào được phép cấp chứng chỉ |

## Các đích ngoài vùng cần kiểm tra trực tiếp (không thực hiện lệnh gọi mạng nào)

| Đích | Được sử dụng bởi |
|---|---|
| bakery-site.example.net. | @ CNAME (dòng 14); www CNAME (dòng 15) |
| mx2.example.net. | backupmx CNAME (dòng 26) |
| ns1.example.net. | @ NS (dòng 10) |
| ns2.example.net. | @ NS (dòng 11) |
| shops.myshopify.com. | shop CNAME (dòng 17) |
FILE:scripts/dns_lint.py
#!/usr/bin/env python3
"""DNS zone record linter (Python 3.8+ standard library only, works offline).

Reads a BIND-style zone file or a CSV record list exported from a DNS provider and
reports common mistakes before they break web or mail delivery:
CNAME at the apex or next to other records, MX/NS pointing at a CNAME or an IP,
missing trailing dots (mail.example.com -> mail.example.com.example.com.), missing or
duplicate SPF, SPF syntax, +all/?all and the 10-lookup limit, DMARC and DKIM presence
and syntax, weak or revoked DKIM keys, TTL extremes, private addresses in a public
zone, SOA mistakes, duplicate records, and domains that send no mail but have no
"no mail" policy. Out-of-zone targets are listed for a separate live check.

Usage:
  python3 dns_lint.py example.com.zone [--origin example.com] [--json]
        [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE]
  python3 dns_lint.py records.csv --origin example.com      (CSV columns: name,type,value[,ttl][,priority])
  python3 dns_lint.py --self-test
Zone files: $ORIGIN, $TTL, @, relative names, blank owner (repeat previous), optional
TTL/class in either order, ( ) multi-line records, ; comments, quoted TXT strings.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, base64, csv, io, ipaddress, json, os, re, sys

SEVERITIES = ["LOW", "MEDIUM", "HIGH"]
CLASSES = {"IN", "CH", "HS"}
TYPES = {"A", "AAAA", "CNAME", "MX", "NS", "TXT", "SPF", "SOA", "SRV", "CAA", "PTR", "DNAME",
         "ALIAS", "ANAME", "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "HTTPS",
         "SVCB", "TLSA", "NAPTR", "SSHFP", "LOC", "HINFO", "CDS", "CDNSKEY"}
DNSSEC_TYPES = {"RRSIG", "NSEC", "NSEC3", "KEY"}
TARGET_TYPES = {"CNAME": -1, "MX": 1, "NS": 0, "PTR": 0, "DNAME": 0, "SRV": 3, "ALIAS": 0, "ANAME": 0}
COMMON_TLDS = set("com net org edu gov mil int io co dev app ai info biz me tv cloud xyz site online "
                  "shop store tech us uk de fr nl eu es it se no dk fi pl cz at ch be pt ie tr ru "
                  "jp cn kr in au nz ca br mx ar za sg hk tw".split())
INTERNAL_NETS = [ipaddress.ip_network(n) for n in (
    "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
    "0.0.0.0/8", "::1/128", "::/128", "fc00::/7", "fe80::/10")]  # documentation ranges (192.0.2.0/24, 2001:db8::/32) are allowed
SPF_LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")

FIXES = {
    "CNAME_AT_APEX": "use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record",
    "CNAME_WITH_OTHER_DATA": "a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA",
    "MULTIPLE_CNAME": "keep exactly one CNAME for the name",
    "MX_TO_CNAME": "point MX at a name with A/AAAA records (the CNAME's final target)",
    "NS_TO_CNAME": "point NS at the name server's real host name (A/AAAA)",
    "TARGET_IS_IP": "use a host name here and give that host an A/AAAA record",
    "MISSING_TRAILING_DOT": "end fully qualified names with a dot (mail.example.com.) or write the relative form (mail)",
    "MAYBE_MISSING_TRAILING_DOT": "if this is an outside host name, add the trailing dot",
    "OWNER_DOUBLED_ORIGIN": "end the owner name with a dot or write it relative (www)",
    "DANGLING_TARGET": "add the missing in-zone record or fix the target name",
    "CNAME_CHAIN": "point straight at the final name; chains add lookups and break when one link changes",
    "MX_TARGET_NO_ADDRESS": "add A/AAAA records for the mail host",
    "NULL_MX_WITH_OTHERS": "a null MX ('0 .') must be the only MX; remove it or the others",
    "SPF_MISSING": "publish one TXT \"v=spf1 ... -all\" (or ~all while testing) listing every service that sends as this domain",
    "SPF_MULTIPLE": "merge into ONE v=spf1 record; two records make every SPF check fail (permerror)",
    "SPF_TYPE99": "delete the SPF-type record; only the TXT record is used (RFC 7208)",
    "SPF_SYNTAX": "fix the term; one bad term makes the whole record a permerror",
    "SPF_PASS_ALL": "end with -all (or ~all while testing); +all lets anyone send as you",
    "SPF_NEUTRAL_ALL": "use ~all or -all; ?all gives receivers no signal",
    "SPF_NO_ALL": "end the record with -all or ~all (or redirect=)",
    "SPF_TERMS_AFTER_ALL": "move 'all' to the end; terms after it are ignored",
    "SPF_TOO_MANY_LOOKUPS": "flatten or remove includes, drop a/mx/ptr terms you do not need; nested includes count too",
    "SPF_LOOKUPS_NEAR_LIMIT": "count nested includes with a live checker; they add to this number",
    "SPF_PTR": "replace ptr with ip4/ip6 or include (ptr is deprecated and slow)",
    "TXT_STRING_TOO_LONG": "split into quoted chunks of at most 255 characters: \"part1\" \"part2\"",
    "DMARC_MISSING": "add TXT at _dmarc: \"v=DMARC1; p=none; rua=mailto:dmarc@<domain>\" then move to quarantine/reject",
    "DMARC_MULTIPLE": "keep one DMARC record; with two, receivers ignore DMARC entirely",
    "DMARC_SYNTAX": "start with v=DMARC1; and include p=none|quarantine|reject",
    "DMARC_WRONG_NAME": "DMARC is only read from _dmarc.<domain>; move the record there",
    "DMARC_MONITOR_ONLY": "after reviewing reports, move to p=quarantine, then p=reject",
    "DMARC_NO_RUA": "add rua=mailto:... so you get aggregate reports",
    "DMARC_PARTIAL_PCT": "raise pct to 100 once reports look clean",
    "DKIM_NOT_FOUND": "publish the DKIM selector record(s) your mail provider gives you (<selector>._domainkey)",
    "DKIM_WRONG_NAME": "DKIM keys live at <selector>._domainkey.<domain>",
    "DKIM_REVOKED": "empty p= revokes the key; remove the record if the selector is retired, or publish the new key",
    "DKIM_BAD_KEY": "copy the key again from the provider (base64, no spaces or quotes inside)",
    "DKIM_WEAK_KEY": "rotate to a 2048-bit RSA key (or add an ed25519 selector alongside RSA)",
    "DKIM_1024_KEY": "plan rotation to a 2048-bit key",
    "NO_MAIL_POLICY": "for a domain that never sends mail: MX 0 . , TXT \"v=spf1 -all\" and _dmarc \"v=DMARC1; p=reject\"",
    "NULL_MX_SPF_NOT_FAIL": "a no-mail domain should publish \"v=spf1 -all\"",
    "TTL_TOO_LOW": "use 300 s or more for normal records (lower only for a planned change)",
    "TTL_TOO_HIGH": "use 1 h to 1 day; above a week, mistakes take a week to undo",
    "TTL_INVALID": "TTL must be 0..2147483647 seconds",
    "NO_DEFAULT_TTL": "add $TTL at the top of the zone (e.g. $TTL 3600)",
    "SOA_MISSING": "a zone file needs exactly one SOA at the apex",
    "SOA_RNAME_AT": "write the contact as hostmaster.example.com. (first @ becomes a dot)",
    "SOA_NEGATIVE_TTL_HIGH": "set the SOA minimum (negative-caching TTL) to 300-3600 s",
    "NS_MISSING": "list the zone's name servers as NS records at the apex",
    "NS_SINGLE": "use at least two name servers on separate networks",
    "BAD_ADDRESS": "fix the address",
    "WRONG_ADDRESS_FAMILY": "A takes IPv4, AAAA takes IPv6",
    "PRIVATE_ADDRESS": "do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed)",
    "DUPLICATE_RECORD": "delete the duplicate",
    "CAA_MISSING": "optional hardening: CAA 0 issue \"<your CA>\" limits who may issue certificates",
    "UNKNOWN_TYPE": "check the record type spelling",
    "PARSE_ERROR": "fix the line syntax",
    "INCLUDE_NOT_FOLLOWED": "lint the included file separately",
}


class InputError(Exception):
    pass


class Rec:
    __slots__ = ("line", "name", "raw_name", "ttl", "type", "rdata", "raw")

    def __init__(self, line, name, raw_name, ttl, rtype, rdata, raw):
        self.line, self.name, self.raw_name, self.ttl = line, name, raw_name, ttl
        self.type, self.rdata, self.raw = rtype, rdata, raw


def parse_ttl(tok):
    t = tok.lower()
    if t.isdigit():
        return int(t)
    m = re.fullmatch(r"((\d+)[wdhms])+", t)
    if not m:
        return None
    mult = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
    return sum(int(n) * mult[u] for n, u in re.findall(r"(\d+)([wdhms])", t))


def tokenize(text):
    """Split one logical line into tokens; quoted strings keep their quotes."""
    toks, i, n = [], 0, len(text)
    while i < n:
        c = text[i]
        if c.isspace():
            i += 1
        elif c == '"':
            j, buf = i + 1, ['"']
            while j < n and text[j] != '"':
                if text[j] == "\\" and j + 1 < n:
                    buf.append(text[j:j + 2]); j += 2; continue
                buf.append(text[j]); j += 1
            if j >= n:
                raise InputError("unterminated quoted string")
            buf.append('"'); toks.append("".join(buf)); i = j + 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] != '"':
                j += 1
            toks.append(text[i:j]); i = j
    return toks


def strip_comment(line):
    out, inq, i = [], False, 0
    while i < len(line):
        c = line[i]
        if c == "\\" and i + 1 < len(line):
            out.append(line[i:i + 2]); i += 2; continue
        if c == '"':
            inq = not inq
        elif c == ";" and not inq:
            break
        out.append(c); i += 1
    return "".join(out)


def logical_lines(text):
    """Yield (line_no, starts_with_space, content) joining ( ... ) groups."""
    buf, start, depth, lead = [], 0, 0, False
    for no, line in enumerate(text.splitlines(), 1):
        body = strip_comment(line)
        if depth == 0:
            if not body.strip():
                continue
            start, lead, buf = no, body[:1].isspace(), []
        inq = False
        clean = []
        for c in body:
            if c == '"':
                inq = not inq
            if not inq and c in "()":
                depth += 1 if c == "(" else -1
                clean.append(" ")
                continue
            clean.append(c)
        buf.append("".join(clean))
        if depth <= 0:
            depth = 0
            yield start, lead, " ".join(buf)
    if depth > 0:
        raise InputError(f"line {start}: '(' is never closed")


def absname(raw, origin):
    if raw == "@":
        return origin
    if raw.endswith("."):
        return raw.lower()
    return (raw + "." + origin).lower() if origin != "." else raw.lower() + "."


def parse_zone(text, origin, findings):
    recs, last_owner, default_ttl, have_ttl_directive = [], None, None, False
    for no, lead, content in logical_lines(text):
        toks = tokenize(content)
        if not toks:
            continue
        if toks[0].upper() == "$ORIGIN":
            if len(toks) < 2:
                raise InputError(f"line {no}: $ORIGIN needs a name")
            origin = absname(toks[1], origin) if not toks[1].endswith(".") else toks[1].lower()
            continue
        if toks[0].upper() == "$TTL":
            v = parse_ttl(toks[1]) if len(toks) > 1 else None
            if v is None:
                raise InputError(f"line {no}: bad $TTL")
            default_ttl, have_ttl_directive = v, True
            continue
        if toks[0].upper() == "$INCLUDE":
            add(findings, "LOW", "INCLUDE_NOT_FOLLOWED", no, "", "", f"$INCLUDE {toks[1] if len(toks) > 1 else ''} was not read")
            continue
        if lead:
            if last_owner is None:
                raise InputError(f"line {no}: record has no owner name and there is no previous one")
            raw_owner = None
        else:
            raw_owner = toks.pop(0)
        ttl, rtype = None, None
        while toks:
            t = toks[0]
            if t.upper() in CLASSES:
                toks.pop(0); continue
            v = parse_ttl(t)
            if v is not None and ttl is None and not t.upper() in TYPES:
                ttl = v; toks.pop(0); continue
            break
        if not toks:
            add(findings, "HIGH", "PARSE_ERROR", no, raw_owner or "", "", f"no record type in '{content.strip()}'")
            continue
        rtype = toks.pop(0).upper()
        if rtype not in TYPES and not re.fullmatch(r"TYPE\d+", rtype):
            add(findings, "MEDIUM", "UNKNOWN_TYPE", no, raw_owner or "", rtype, f"unknown record type '{rtype}'")
        if raw_owner is not None:
            name = absname(raw_owner, origin)
            last_owner = (name, raw_owner)
        name, raw_name = last_owner
        recs.append(Rec(no, name, raw_name, ttl, rtype, toks, content.strip()))
    return recs, origin, default_ttl, have_ttl_directive


def parse_csv(text, origin, findings):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("CSV has no rows")
    cols = {c.lower().strip(): c for c in rows[0].keys() if c}
    for need in ("name", "type", "value"):
        if need not in cols:
            raise InputError(f"CSV needs columns name,type,value[,ttl][,priority] (missing '{need}')")
    recs = []
    base = origin.rstrip(".")
    for i, r in enumerate(rows, 2):
        g = lambda k: (r.get(cols[k]) or "").strip() if k in cols else ""
        raw = g("name") or "@"
        low = raw.lower().rstrip(".")
        if raw == "@" or low == base:
            name = origin
        elif low.endswith("." + base):
            name = low + "."
        else:
            name = low + "." + origin
        rtype = g("type").upper()
        value = g("value")
        ttl = parse_ttl(g("ttl")) if g("ttl") else None
        if rtype in ("TXT", "SPF"):
            v = value
            if not v.startswith('"'):
                v = '"' + v.replace('"', '\\"') + '"'
            toks = tokenize(v)
        else:
            toks = value.split()
            if rtype in TARGET_TYPES and toks:  # provider UIs show host names without the dot
                idx = TARGET_TYPES[rtype]
                if rtype == "MX" and len(toks) == 1:
                    toks = [g("priority") or "10"] + toks
                if rtype == "SRV" and len(toks) == 3 and g("priority"):
                    toks = [g("priority")] + toks
                k = len(toks) - 1 if idx == -1 else min(idx, len(toks) - 1)
                if not toks[k].endswith(".") and toks[k] != "@":
                    toks[k] += "."
        recs.append(Rec(i, name, raw, ttl, rtype, toks, ",".join(r.get(c) or "" for c in r)))
    return recs, origin


def add(findings, sev, code, line, name, rtype, problem):
    findings.append({"severity": sev, "code": code, "line": line, "name": name, "type": rtype,
                     "problem": problem, "fix": FIXES.get(code, "")})


def txt_strings(rec):
    out = []
    for t in rec.rdata:
        if t.startswith('"') and t.endswith('"'):
            out.append(re.sub(r"\\(.)", r"\1", t[1:-1]))
        else:
            out.append(t)
    return out


def short(name, origin):
    if name == origin:
        return "@"
    if name.endswith("." + origin):
        return name[: -len(origin) - 1]
    return name


def lint(text, origin=None, fmt=None, filename=""):
    findings = []
    if fmt is None:
        fmt = "csv" if filename.lower().endswith(".csv") or text.lstrip().lower().startswith("name,") else "zone"
    if origin is None:
        m = re.search(r"^\$ORIGIN\s+(\S+)", text, re.M | re.I)
        if m:
            origin = m.group(1)
        elif filename:
            b = os.path.basename(filename)
            for suf in (".zone", ".db", ".txt", ".csv"):
                if b.lower().endswith(suf):
                    b = b[: -len(suf)]
            for pre in ("db.",):
                if b.startswith(pre):
                    b = b[len(pre):]
            origin = b if "." in b else None
        if not origin:
            raise InputError("cannot tell the zone name: add $ORIGIN or pass --origin example.com")
    origin = origin.lower().rstrip(".") + "."
    if fmt == "csv":
        recs, origin = parse_csv(text, origin, findings)
        default_ttl, have_ttl = None, True
    else:
        recs, origin, default_ttl, have_ttl = parse_zone(text, origin, findings)
    base = origin.rstrip(".")
    by_name = {}
    for r in recs:
        by_name.setdefault(r.name, []).append(r)
    types_at = lambda n: {r.type for r in by_name.get(n, [])}
    in_zone = lambda n: n == origin or n.endswith("." + origin)
    externals = {}

    # SOA / NS / TTL
    soa = [r for r in recs if r.type == "SOA"]
    if fmt == "zone":
        if not soa:
            add(findings, "MEDIUM", "SOA_MISSING", 0, "@", "SOA", "no SOA record in the zone file")
        for r in soa:
            if len(r.rdata) >= 7:
                rname = r.rdata[1]
                if "@" in rname:
                    add(findings, "HIGH", "SOA_RNAME_AT", r.line, short(r.name, origin), "SOA", f"contact '{rname}' contains '@'; in DNS it is written with a dot")
                neg = parse_ttl(r.rdata[6])
                if neg is not None and neg > 86400:
                    add(findings, "LOW", "SOA_NEGATIVE_TTL_HIGH", r.line, short(r.name, origin), "SOA", f"SOA minimum {neg} s: a deleted-then-added name stays 'does not exist' in caches that long")
                if default_ttl is None and neg is not None:
                    default_ttl = neg
            else:
                add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), "SOA", "SOA needs 7 fields: mname rname serial refresh retry expire minimum")
        ns_apex = [r for r in by_name.get(origin, []) if r.type == "NS"]
        if not ns_apex:
            add(findings, "MEDIUM", "NS_MISSING", 0, "@", "NS", "no NS records at the apex")
        elif len(ns_apex) == 1:
            add(findings, "MEDIUM", "NS_SINGLE", ns_apex[0].line, "@", "NS", "only one name server: if it is down, the whole domain is down")
        if not have_ttl:
            add(findings, "LOW", "NO_DEFAULT_TTL", 0, "", "", "no $TTL directive; records without a TTL fall back to the SOA minimum or the server default")

    for r in recs:
        ttl = r.ttl if r.ttl is not None else default_ttl
        if ttl is None:
            continue
        nm = short(r.name, origin)
        if ttl > 2147483647:
            add(findings, "HIGH", "TTL_INVALID", r.line, nm, r.type, f"TTL {ttl} is above 2147483647")
        elif ttl > 604800:
            add(findings, "MEDIUM", "TTL_TOO_HIGH", r.line, nm, r.type, f"TTL {ttl} s ({ttl // 86400} days): a wrong value stays cached that long")
        elif ttl < 60 and not r.name.startswith("_acme-challenge."):
            add(findings, "MEDIUM", "TTL_TOO_LOW", r.line, nm, r.type, f"TTL {ttl} s: extra query load, slower lookups, and many resolvers raise it anyway")

    # owner names that repeat the origin
    for r in recs:
        if fmt == "zone" and r.raw_name not in (None, "@") and not r.raw_name.endswith(".") and \
                (r.raw_name.lower() == base or r.raw_name.lower().endswith("." + base)):
            add(findings, "HIGH", "OWNER_DOUBLED_ORIGIN", r.line, r.raw_name, r.type, f"owner '{r.raw_name}' has no trailing dot, so it becomes {r.name}")

    # CNAME rules
    for n, rs in by_name.items():
        cn = [r for r in rs if r.type == "CNAME"]
        if not cn:
            continue
        nm = short(n, origin)
        if n == origin:
            add(findings, "HIGH", "CNAME_AT_APEX", cn[0].line, nm, "CNAME", "CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break")
        others = sorted({r.type for r in rs if r.type not in ("CNAME",) and r.type not in DNSSEC_TYPES})
        if others and n != origin:
            add(findings, "HIGH", "CNAME_WITH_OTHER_DATA", cn[0].line, nm, "CNAME", f"name has a CNAME and also {', '.join(others)}; resolvers return only the CNAME")
        if len(cn) > 1:
            add(findings, "HIGH", "MULTIPLE_CNAME", cn[1].line, nm, "CNAME", f"{len(cn)} CNAME records for one name")

    # targets
    for r in recs:
        if r.type not in TARGET_TYPES or not r.rdata:
            continue
        idx = TARGET_TYPES[r.type]
        if r.type == "MX" and len(r.rdata) < 2 or r.type == "SRV" and len(r.rdata) < 4:
            add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), r.type, f"{r.type} needs " + ("preference and host" if r.type == "MX" else "priority weight port target"))
            continue
        raw = r.rdata[idx] if idx >= 0 else r.rdata[-1]
        nm = short(r.name, origin)
        if r.type == "MX" and raw == ".":
            continue
        try:
            ipaddress.ip_address(raw.rstrip("."))
            add(findings, "HIGH", "TARGET_IS_IP", r.line, nm, r.type, f"{r.type} target '{raw}' is an IP address; {r.type} must name a host")
            continue
        except ValueError:
            pass
        tgt = absname(raw, origin)
        if fmt == "zone" and raw != "@" and not raw.endswith("."):
            low = raw.lower()
            if low == base or low.endswith("." + base):
                add(findings, "HIGH", "MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."  # check the name that was meant
            elif "." in low and low.rsplit(".", 1)[1] in COMMON_TLDS:
                add(findings, "MEDIUM", "MAYBE_MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' looks like a full host name but has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."
        if in_zone(tgt):
            tt = types_at(tgt)
            if r.type in ("MX", "NS", "SRV") and "CNAME" in tt:
                code = {"MX": "MX_TO_CNAME", "NS": "NS_TO_CNAME", "SRV": "MX_TO_CNAME"}[r.type]
                add(findings, "HIGH", code, r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} is a CNAME (not allowed, RFC 2181 10.3)")
            elif r.type == "CNAME" and "CNAME" in tt:
                add(findings, "LOW", "CNAME_CHAIN", r.line, nm, r.type, f"CNAME to {short(tgt, origin)}, which is itself a CNAME")
            elif not tt:
                # wildcard or a deeper/glue name may exist elsewhere; only flag when no wildcard covers it
                wild = "*." + tgt.split(".", 1)[1] if "." in tgt else ""
                if wild not in by_name:
                    add(findings, "MEDIUM", "DANGLING_TARGET", r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} has no records in this zone")
            elif r.type == "MX" and not (tt & {"A", "AAAA"}):
                add(findings, "MEDIUM", "MX_TARGET_NO_ADDRESS", r.line, nm, r.type, f"mail host {short(tgt, origin)} has no A/AAAA record")
        else:
            externals.setdefault(tgt, []).append(f"{nm} {r.type} (line {r.line})")

    # addresses
    for r in recs:
        if r.type not in ("A", "AAAA") or not r.rdata:
            continue
        nm = short(r.name, origin)
        try:
            ip = ipaddress.ip_address(r.rdata[0])
        except ValueError:
            add(findings, "HIGH", "BAD_ADDRESS", r.line, nm, r.type, f"'{r.rdata[0]}' is not an IP address")
            continue
        if (r.type == "A") != (ip.version == 4):
            add(findings, "HIGH", "WRONG_ADDRESS_FAMILY", r.line, nm, r.type, f"{r.type} record holds an IPv{ip.version} address")
        elif any(ip in n for n in INTERNAL_NETS if n.version == ip.version):
            add(findings, "MEDIUM", "PRIVATE_ADDRESS", r.line, nm, r.type, f"{ip} is a private/loopback address; outside users cannot reach it and it reveals internal layout")

    # duplicates
    seen = {}
    for r in recs:
        key = (r.name, r.type, " ".join(x.lower() for x in r.rdata))
        if key in seen:
            add(findings, "LOW", "DUPLICATE_RECORD", r.line, short(r.name, origin), r.type, f"same as line {seen[key]}")
        else:
            seen[key] = r.line

    # TXT string length (zone files only: each quoted string max 255)
    if fmt == "zone":
        for r in recs:
            if r.type in ("TXT", "SPF"):
                for s in txt_strings(r):
                    if len(s.encode()) > 255:
                        add(findings, "HIGH", "TXT_STRING_TOO_LONG", r.line, short(r.name, origin), r.type, f"one quoted string is {len(s.encode())} bytes (max 255); the server rejects the zone or truncates it")
                        break

    # mail: MX, SPF, DMARC, DKIM
    txt_by_name = {}
    for r in recs:
        if r.type == "TXT":
            txt_by_name.setdefault(r.name, []).append((r, "".join(txt_strings(r))))
    for r in recs:
        if r.type == "SPF":
            add(findings, "LOW", "SPF_TYPE99", r.line, short(r.name, origin), "SPF", "SPF-type (99) record is obsolete and ignored")
    spf_at = {}
    for n, items in txt_by_name.items():
        for r, s in items:
            if re.match(r"(?i)v=spf1(\s|$)", s):
                spf_at.setdefault(n, []).append((r, s))
            if re.match(r"(?i)v=dmarc1", s) and not n.startswith("_dmarc."):
                add(findings, "HIGH", "DMARC_WRONG_NAME", r.line, short(n, origin), "TXT", "DMARC record is not at _dmarc.<domain>, so receivers never see it")
            if re.match(r"(?i)v=dkim1", s) and "._domainkey." not in "." + n:
                add(findings, "HIGH", "DKIM_WRONG_NAME", r.line, short(n, origin), "TXT", "DKIM key is not under <selector>._domainkey, so signatures cannot be verified")
    for n, items in spf_at.items():
        nm = short(n, origin)
        if len(items) > 1:
            add(findings, "HIGH", "SPF_MULTIPLE", items[1][0].line, nm, "TXT", f"{len(items)} v=spf1 records at this name (lines {', '.join(str(i[0].line) for i in items)})")
        for r, s in items:
            check_spf(findings, r, s, nm)

    mx_names = {}
    for r in recs:
        if r.type == "MX" and len(r.rdata) >= 2:
            mx_names.setdefault(r.name, []).append(r)
    for n, mxs in mx_names.items():
        nulls = [r for r in mxs if r.rdata[1] == "."]
        if nulls and len(mxs) > 1:
            add(findings, "HIGH", "NULL_MX_WITH_OTHERS", nulls[0].line, short(n, origin), "MX", "null MX ('0 .') next to other MX records")
    apex_mx = [r for r in mx_names.get(origin, []) if r.rdata[1] != "."]
    apex_null = any(r.rdata[1] == "." for r in mx_names.get(origin, []))
    apex_spf = spf_at.get(origin, [])
    spf_sends = any(not re.fullmatch(r"(?i)v=spf1\s+-all", s.strip()) for _, s in apex_spf)
    sends_mail = bool(apex_mx) or spf_sends
    for n, mxs in mx_names.items():
        if any(r.rdata[1] != "." for r in mxs) and n not in spf_at:
            add(findings, "MEDIUM", "SPF_MISSING", mxs[0].line, short(n, origin), "TXT", "name receives mail (MX) but publishes no SPF record")
    if apex_null and apex_spf and spf_sends:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", apex_spf[0][0].line, "@", "TXT", "null MX says no mail, but SPF still authorizes senders")
    if apex_null and not apex_spf:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", 0, "@", "TXT", "null MX but no \"v=spf1 -all\"")

    dm = [(r, s) for r, s in txt_by_name.get("_dmarc." + origin, []) if re.match(r"(?i)v=dmarc1", s.strip()) or "p=" in s.lower()]
    dkim_names = [n for n in by_name if n.endswith("._domainkey." + origin)]
    if sends_mail or apex_null:
        if not dm:
            add(findings, "MEDIUM", "DMARC_MISSING", 0, "_dmarc", "TXT", "no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders")
    if dm:
        if len(dm) > 1:
            add(findings, "HIGH", "DMARC_MULTIPLE", dm[1][0].line, "_dmarc", "TXT", f"{len(dm)} DMARC records")
        for r, s in dm:
            check_dmarc(findings, r, s)
    if sends_mail and not dkim_names:
        add(findings, "MEDIUM", "DKIM_NOT_FOUND", 0, "*._domainkey", "TXT", "no <selector>._domainkey record: if you send mail, it is probably unsigned (checked only names in this file)")
    for n in dkim_names:
        for r in by_name[n]:
            if r.type == "TXT":
                check_dkim(findings, r, "".join(txt_strings(r)), short(n, origin))
    if not sends_mail and not apex_null and not apex_spf:
        add(findings, "LOW", "NO_MAIL_POLICY", 0, "@", "MX", "no MX and no SPF: if this domain never sends mail, say so, or spammers can spoof it freely")

    has_web = any(r.type in ("A", "AAAA", "CNAME", "ALIAS", "ANAME") for r in recs)
    if has_web and "CAA" not in types_at(origin):
        add(findings, "LOW", "CAA_MISSING", 0, "@", "CAA", "no CAA record: any public CA may issue certificates for this domain")

    order = {s: i for i, s in enumerate(reversed(SEVERITIES))}
    findings.sort(key=lambda f: (f["line"] if f["line"] else 10 ** 6, order[f["severity"]], f["code"]))
    return {"origin": origin, "format": fmt, "records": len(recs), "findings": findings,
            "external_targets": [{"target": t, "used_by": u} for t, u in sorted(externals.items())]}


def check_spf(findings, r, s, nm):
    terms = s.split()[1:]
    lookups, seen_all = 0, None
    for i, t in enumerate(terms):
        q = t[0] if t[0] in "+-~?" else ""
        body = t[len(q):].lower()
        mech = re.split(r"[:=/]", body, maxsplit=1)[0]
        if seen_all is not None and not body.startswith("exp="):
            add(findings, "MEDIUM", "SPF_TERMS_AFTER_ALL", r.line, nm, "TXT", f"'{t}' comes after 'all' and is ignored")
            seen_all = None
        if mech == "all":
            if q in ("", "+"):
                add(findings, "HIGH", "SPF_PASS_ALL", r.line, nm, "TXT", f"'{t}' authorizes every server on the internet to send as this domain")
            elif q == "?":
                add(findings, "MEDIUM", "SPF_NEUTRAL_ALL", r.line, nm, "TXT", "'?all' is neutral: unauthorized senders are not marked")
            seen_all = i
            continue
        if mech in ("include", "exists"):
            if ":" not in body or not body.split(":", 1)[1]:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' needs a domain ({mech}:example.net)")
            lookups += 1
        elif mech in ("a", "mx"):
            lookups += 1
        elif mech == "ptr":
            lookups += 1
            add(findings, "LOW", "SPF_PTR", r.line, nm, "TXT", "'ptr' is deprecated (RFC 7208 5.5)")
        elif mech in ("ip4", "ip6"):
            val = body.split(":", 1)[1] if ":" in body else ""
            try:
                net = ipaddress.ip_network(val, strict=False)
                if (mech == "ip4") != (net.version == 4):
                    raise ValueError
            except ValueError:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' is not a valid {mech} address or range")
        elif mech == "redirect":
            lookups += 1
            if "=" not in body:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}': use redirect=domain")
        elif mech == "exp":
            pass
        else:
            add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"unknown SPF term '{t}' (typo? e.g. ipv4: for ip4:, include= for include:)")
    if not any(re.sub(r"^[+\-~?]", "", t).lower() == "all" for t in terms) and not any(t.lower().startswith("redirect=") for t in terms):
        add(findings, "MEDIUM", "SPF_NO_ALL", r.line, nm, "TXT", "record has no 'all' or redirect=, so unlisted senders get a neutral result")
    if lookups > 10:
        add(findings, "HIGH", "SPF_TOO_MANY_LOOKUPS", r.line, nm, "TXT", f"{lookups} DNS-lookup terms at the top level alone (limit 10, RFC 7208 4.6.4); receivers return permerror")
    elif lookups >= 8:
        add(findings, "LOW", "SPF_LOOKUPS_NEAR_LIMIT", r.line, nm, "TXT", f"{lookups} lookup terms before counting nested includes (limit 10)")


def check_dmarc(findings, r, s):
    tags = {}
    parts = [p.strip() for p in s.split(";") if p.strip()]
    if not parts or parts[0].replace(" ", "").upper() != "V=DMARC1":
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "record must start with 'v=DMARC1;'")
    for p in parts:
        k, _, v = p.partition("=")
        tags[k.strip().lower()] = v.strip()
    p = tags.get("p", "").lower()
    if p not in ("none", "quarantine", "reject"):
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "missing or invalid p= (none, quarantine or reject); receivers ignore the record")
        return
    if p == "none":
        add(findings, "LOW", "DMARC_MONITOR_ONLY", r.line, "_dmarc", "TXT", "p=none only monitors; spoofed mail is still delivered")
    if "rua" not in tags:
        add(findings, "LOW", "DMARC_NO_RUA", r.line, "_dmarc", "TXT", "no rua= address, so nobody sees the aggregate reports")
    if tags.get("pct", "100").isdigit() and int(tags.get("pct", "100")) < 100:
        add(findings, "LOW", "DMARC_PARTIAL_PCT", r.line, "_dmarc", "TXT", f"pct={tags['pct']}: the policy applies to only part of the failing mail")


def check_dkim(findings, r, s, nm):
    tags = {}
    for p in s.split(";"):
        k, _, v = p.strip().partition("=")
        if k:
            tags[k.strip().lower()] = re.sub(r"\s+", "", v)
    if "p" not in tags:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "no p= tag: not a DKIM key record")
        return
    if tags["p"] == "":
        add(findings, "MEDIUM", "DKIM_REVOKED", r.line, nm, "TXT", "p= is empty: this selector is revoked and mail signed with it fails DKIM")
        return
    try:
        raw = base64.b64decode(tags["p"], validate=True)
    except Exception:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "p= is not valid base64 (stray quote, space or cut-off copy?)")
        return
    if tags.get("k", "rsa").lower() == "ed25519":
        return
    bits = rsa_bits(raw)
    if bits is None:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", f"p= decodes to {len(raw)} bytes but is not an RSA public key (cut off?)")
    elif bits < 1024:
        add(findings, "HIGH", "DKIM_WEAK_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; keys under 1024 bits are rejected by receivers (RFC 8301)")
    elif bits < 2048:
        add(findings, "LOW", "DKIM_1024_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; 2048 is the current recommendation")


def der_read(b, i):
    tag = b[i]; i += 1
    ln = b[i]; i += 1
    if ln & 0x80:
        n = ln & 0x7F
        ln = int.from_bytes(b[i:i + n], "big"); i += n
    return tag, i, ln


def rsa_bits(raw):
    """Modulus size of an RSA SubjectPublicKeyInfo (or bare RSAPublicKey) in bits."""
    try:
        tag, i, ln = der_read(raw, 0)
        if tag != 0x30:
            return None
        tag, j, l2 = der_read(raw, i)
        if tag == 0x30:  # SubjectPublicKeyInfo: algorithm, then BIT STRING
            j += l2
            tag, j, l3 = der_read(raw, j)
            if tag != 0x03:
                return None
            j += 1  # unused-bits byte
            tag, j, l4 = der_read(raw, j)
            if tag != 0x30:
                return None
            tag, j, lm = der_read(raw, j)
        elif tag == 0x02:
            lm = l2
        else:
            return None
        if tag != 0x02 or j + lm > len(raw):
            return None
        mod = raw[j:j + lm].lstrip(b"\x00")
        return len(mod) * 8 if mod else None
    except IndexError:
        return None


def render(res, title, min_sev="LOW", ignore=()):
    keep = [f for f in res["findings"] if SEVERITIES.index(f["severity"]) >= SEVERITIES.index(min_sev) and f["code"] not in ignore]
    cnt = {s: sum(1 for f in keep if f["severity"] == s) for s in SEVERITIES}
    out = [f"# DNS record lint: {title}", "",
           f"Zone: {res['origin']}   Format: {res['format']}   Records: {res['records']}   Findings: {len(keep)} (HIGH {cnt['HIGH']}, MEDIUM {cnt['MEDIUM']}, LOW {cnt['LOW']})", ""]
    if keep:
        out += ["| Sev | Code | Line | Name | Type | Problem | Fix |", "|---|---|---|---|---|---|---|"]
        for f in keep:
            out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['name'] or '-'} | {f['type'] or '-'} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    if res["external_targets"]:
        out += ["", "## Out-of-zone targets to check live (no network calls were made)", "",
                "| Target | Used by |", "|---|---|"]
        for e in res["external_targets"]:
            out.append(f"| {e['target']} | {'; '.join(e['used_by'])} |")
    return "\n".join(out) + "\n", keep


def self_test():
    Z = "$ORIGIN example.com.\n$TTL 3600\n@ IN SOA ns1 hostmaster 1 7200 900 1209600 300\n@ NS ns1\n@ NS ns2.example.net.\nns1 A 192.0.2.53\n"
    MAIL = '@ MX 10 mail\nmail A 192.0.2.25\n@ TXT "v=spf1 mx -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\ns1._domainkey CNAME s1.dkim.example.net.\n@ CAA 0 issue "letsencrypt.org"\n'
    k1024 = base64.b64encode(bytes.fromhex("30819f300d06092a864886f70d010101050003818d0030818902818100") + b"\xc1" + bytes(127) + bytes.fromhex("0203010001")).decode()
    k512 = base64.b64encode(bytes.fromhex("305c300d06092a864886f70d0101010500034b003048024100") + b"\xc1" + bytes(63) + bytes.fromhex("0203010001")).decode()

    def codes(text, **kw):
        return [f["code"] for f in lint(text, **kw)["findings"]]

    cases = [
        ("clean zone", Z + MAIL, lambda c: c == []),
        ("cname at apex", Z + MAIL + "@ CNAME shop.example.net.\n", lambda c: "CNAME_AT_APEX" in c),
        ("cname with other data", Z + MAIL + "www CNAME web.example.net.\nwww TXT \"x\"\n", lambda c: "CNAME_WITH_OTHER_DATA" in c),
        ("multiple cname", Z + MAIL + "www CNAME a.example.net.\nwww CNAME b.example.net.\n", lambda c: "MULTIPLE_CNAME" in c),
        ("mx to cname", Z + MAIL.replace("mail A 192.0.2.25", "mail CNAME mx.example.net."), lambda c: "MX_TO_CNAME" in c),
        ("ns to cname", Z.replace("ns1 A 192.0.2.53", "ns1 CNAME dns.example.net.") + MAIL, lambda c: "NS_TO_CNAME" in c),
        ("mx is ip", Z + MAIL.replace("MX 10 mail", "MX 10 192.0.2.25"), lambda c: "TARGET_IS_IP" in c),
        ("missing trailing dot", Z + MAIL + "www CNAME web.example.com\nweb A 192.0.2.80\n", lambda c: "MISSING_TRAILING_DOT" in c),
        ("maybe missing dot (outside name)", Z + MAIL + "shop CNAME shops.myshopify.com\n", lambda c: "MAYBE_MISSING_TRAILING_DOT" in c),
        ("owner doubled origin", Z + MAIL + "www.example.com A 192.0.2.80\n", lambda c: "OWNER_DOUBLED_ORIGIN" in c),
        ("relative target ok", Z + MAIL + "www CNAME web\nweb A 192.0.2.80\n", lambda c: c == []),
        ("dangling target", Z + MAIL + "www CNAME web\n", lambda c: "DANGLING_TARGET" in c),
        ("cname chain", Z + MAIL + "www CNAME web\nweb CNAME edge.example.net.\n", lambda c: "CNAME_CHAIN" in c),
        ("mx target no address", Z + MAIL.replace("mail A 192.0.2.25", "mail TXT \"x\""), lambda c: "MX_TARGET_NO_ADDRESS" in c),
        ("spf missing", Z + MAIL.replace('@ TXT "v=spf1 mx -all"\n', ""), lambda c: "SPF_MISSING" in c),
        ("spf multiple", Z + MAIL + '@ TXT "v=spf1 include:_spf.google.com ~all"\n', lambda c: "SPF_MULTIPLE" in c),
        ("spf +all", Z + MAIL.replace("mx -all", "mx +all"), lambda c: "SPF_PASS_ALL" in c),
        ("spf ?all", Z + MAIL.replace("mx -all", "mx ?all"), lambda c: "SPF_NEUTRAL_ALL" in c),
        ("spf no all", Z + MAIL.replace("mx -all", "mx"), lambda c: "SPF_NO_ALL" in c),
        ("spf terms after all", Z + MAIL.replace("mx -all", "-all mx"), lambda c: "SPF_TERMS_AFTER_ALL" in c),
        ("spf syntax typo", Z + MAIL.replace("mx -all", "ipv4:192.0.2.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf bad ip4", Z + MAIL.replace("mx -all", "ip4:300.1.1.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf too many lookups", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(11)) + " -all"), lambda c: "SPF_TOO_MANY_LOOKUPS" in c),
        ("spf near limit", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(8)) + " -all"), lambda c: "SPF_LOOKUPS_NEAR_LIMIT" in c and "SPF_TOO_MANY_LOOKUPS" not in c),
        ("spf ptr", Z + MAIL.replace("mx -all", "ptr -all"), lambda c: "SPF_PTR" in c),
        ("spf type99", Z + MAIL + '@ SPF "v=spf1 mx -all"\n', lambda c: "SPF_TYPE99" in c),
        ("split txt strings joined", Z + MAIL.replace('"v=spf1 mx -all"', '"v=spf1 mx " "-all"'), lambda c: c == []),
        ("txt string too long", Z + MAIL + '@ TXT "' + "a" * 300 + '"\n', lambda c: "TXT_STRING_TOO_LONG" in c),
        ("dmarc missing", Z + MAIL.replace('_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n', ""), lambda c: "DMARC_MISSING" in c),
        ("dmarc wrong name", Z + MAIL.replace("_dmarc TXT", "dmarc TXT"), lambda c: "DMARC_WRONG_NAME" in c and "DMARC_MISSING" in c),
        ("dmarc multiple", Z + MAIL + '_dmarc TXT "v=DMARC1; p=none"\n', lambda c: "DMARC_MULTIPLE" in c),
        ("dmarc no p", Z + MAIL.replace("p=reject; ", ""), lambda c: "DMARC_SYNTAX" in c),
        ("dmarc none + no rua + pct", Z + MAIL.replace("p=reject; rua=mailto:d@example.com", "p=none; pct=50"), lambda c: {"DMARC_MONITOR_ONLY", "DMARC_NO_RUA", "DMARC_PARTIAL_PCT"} <= set(c)),
        ("dkim not found", Z + MAIL.replace("s1._domainkey CNAME s1.dkim.example.net.\n", ""), lambda c: "DKIM_NOT_FOUND" in c),
        ("dkim revoked", Z + MAIL + 's2._domainkey TXT "v=DKIM1; k=rsa; p="\n', lambda c: "DKIM_REVOKED" in c),
        ("dkim bad base64", Z + MAIL + 's2._domainkey TXT "v=DKIM1; p=MIGf!!"\n', lambda c: "DKIM_BAD_KEY" in c),
        ("dkim 512-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k512}"\n', lambda c: "DKIM_WEAK_KEY" in c),
        ("dkim 1024-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k1024}"\n', lambda c: "DKIM_1024_KEY" in c),
        ("dkim wrong name", Z + MAIL + f's2.domainkey TXT "v=DKIM1; p={k1024}"\n', lambda c: "DKIM_WRONG_NAME" in c),
        ("null mx + spf -all, no mail checks", Z + '@ MX 0 .\n@ TXT "v=spf1 -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n@ CAA 0 issue "letsencrypt.org"\n', lambda c: c == []),
        ("null mx with others", Z + MAIL + "@ MX 0 .\n", lambda c: "NULL_MX_WITH_OTHERS" in c),
        ("no mail policy", Z + '@ CAA 0 issue "letsencrypt.org"\n', lambda c: "NO_MAIL_POLICY" in c),
        ("ttl too low", Z + MAIL + "www 30 A 192.0.2.80\n", lambda c: "TTL_TOO_LOW" in c),
        ("acme ttl low ok", Z + MAIL + '_acme-challenge 30 TXT "token"\n', lambda c: c == []),
        ("ttl too high (units)", Z + MAIL + "www 2w A 192.0.2.80\n", lambda c: "TTL_TOO_HIGH" in c),
        ("no $TTL", Z.replace("$TTL 3600\n", "") + MAIL, lambda c: "NO_DEFAULT_TTL" in c),
        ("soa rname with @", Z.replace("hostmaster", "hostmaster@example.com.") + MAIL, lambda c: "SOA_RNAME_AT" in c),
        ("soa multi-line with comments", Z.replace("@ IN SOA ns1 hostmaster 1 7200 900 1209600 300", "@ IN SOA ns1 hostmaster (\n 2026103001 ; serial\n 7200 900 1209600\n 300 ) ; min") + MAIL, lambda c: c == []),
        ("single ns", Z.replace("@ NS ns2.example.net.\n", "") + MAIL, lambda c: "NS_SINGLE" in c),
        ("bad address", Z + MAIL + "www A 192.0.2.300\n", lambda c: "BAD_ADDRESS" in c),
        ("wrong family", Z + MAIL + "www AAAA 192.0.2.80\n", lambda c: "WRONG_ADDRESS_FAMILY" in c),
        ("private address", Z + MAIL + "intranet A 10.0.0.5\n", lambda c: "PRIVATE_ADDRESS" in c),
        ("duplicate record", Z + MAIL + "www A 192.0.2.80\nwww A 192.0.2.80\n", lambda c: "DUPLICATE_RECORD" in c),
        ("blank owner continues", Z + MAIL + "www A 192.0.2.80\n    AAAA 2001:db8::80\n", lambda c: c == []),
        ("semicolon inside quotes kept", Z + MAIL.replace('"v=DMARC1; p=reject; rua=mailto:d@example.com"', '"v=DMARC1; p=reject; rua=mailto:d@example.com" ; comment'), lambda c: c == []),
        ("caa missing", Z + MAIL.replace('@ CAA 0 issue "letsencrypt.org"\n', ""), lambda c: c == ["CAA_MISSING"]),
        ("csv input", None, None),
        ("unterminated quote is input error", None, None),
    ]
    passed = 0
    for name, text, check in cases:
        try:
            if name == "csv input":
                csvt = ("name,type,value,ttl,priority\n@,MX,mail.example.com,3600,10\nmail,A,192.0.2.25,3600,\n"
                        "@,TXT,v=spf1 include:_spf.google.com ~all,3600,\n@,TXT,v=spf1 mx -all,3600,\n"
                        "www.example.com,CNAME,example.com,300,\n@,CNAME,shop.example.net,300,\n")
                res = lint(csvt, origin="example.com", fmt="csv")
                c = [f["code"] for f in res["findings"]]
                ok = {"SPF_MULTIPLE", "CNAME_AT_APEX", "DMARC_MISSING", "DKIM_NOT_FOUND"} <= set(c) and "MISSING_TRAILING_DOT" not in c and "MX_TO_CNAME" not in c
            elif name == "unterminated quote is input error":
                try:
                    lint(Z + '@ TXT "v=spf1 -all\n')
                    ok, c = False, "no error"
                except InputError as e:
                    ok, c = True, str(e)
            else:
                c = codes(text)
                ok = check(c)
        except Exception as e:  # noqa
            ok, c = False, f"exception {e!r}"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  got {c}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Lint a DNS zone file or record list (offline)")
    ap.add_argument("file", nargs="?")
    ap.add_argument("--origin", help="zone name, e.g. example.com (default: $ORIGIN or the file name)")
    ap.add_argument("--format", choices=["zone", "csv"], help="input format (default: by extension/content)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--min-severity", default="LOW", choices=SEVERITIES)
    ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("give a zone file or CSV, or --self-test")
    try:
        with open(a.file, encoding="utf-8") as fh:
            text = fh.read()
        res = lint(text, origin=a.origin, fmt=a.format, filename=a.file)
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    ignore = {c.strip().upper() for c in a.ignore.split(",") if c.strip()}
    md, keep = render(res, os.path.basename(a.file), a.min_severity, ignore)
    if a.json:
        print(json.dumps({**res, "findings": keep}, indent=2))
    else:
        sys.stdout.write(md)
    return 1 if any(f["severity"] == "HIGH" for f in keep) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonDNS+2
F@f
0
Công Cụ Lập Lịch Tưới Cây Trong Nhà
Kỹ năng

Xây dựng lịch kiểm tra tưới cây trong nhà từ danh sách các loại cây cùng kích thước và chất liệu chậu, điều kiện ánh sáng, khả năng thoát nước và ngày tưới gần nhất. Điều chỉnh từng khoảng thời gian chờ theo loại chậu, ánh sáng, độ ẩm và mùa (theo bán cầu, giãn cách dài hơn vào mùa đông), sắp xếp lịch kiểm tra vào các ngày cố định trong tuần đã chọn, giải thích rõ thế nào là 'cần tưới nước' và lượng nước cần tưới bao nhiêu, đồng thời cảnh báo các rủi ro như chậu không có lỗ thoát nước. Bao gồm bộ lập lịch bằng Python sử dụng thư viện chuẩn (stdlib) có tính năng xuất tệp .ics và --self-test.

---
name: houseplant-watering-schedule-planner
description: Xây dựng lịch kiểm tra tưới cây trong nhà từ danh sách các loại cây cùng kích thước và chất liệu chậu, điều kiện ánh sáng, khả năng thoát nước và ngày tưới gần nhất. Điều chỉnh từng khoảng thời gian chờ theo loại chậu, ánh sáng, độ ẩm và mùa (theo bán cầu, giãn cách dài hơn vào mùa đông), sắp xếp lịch kiểm tra vào các ngày cố định trong tuần đã chọn, giải thích rõ thế nào là 'cần tưới nước' và lượng nước cần tưới bao nhiêu, đồng thời cảnh báo các rủi ro như chậu không có lỗ thoát nước. Bao gồm bộ lập lịch bằng Python sử dụng thư viện chuẩn (stdlib) có tính năng xuất tệp .ics và --self-test.
---

# Công Cụ Lập Lịch Tưới Cây Trong Nhà

Bạn giúp mọi người giữ cho cây trồng trong nhà luôn tươi tốt mà không chỉ tưới máy móc theo lịch. Kế hoạch sẽ cho họ biết khi nào cần **kiểm tra** từng cây và cần quan sát dấu hiệu gì; họ chỉ tưới nước khi quy tắc riêng của cây đó báo đã đến lúc. Bạn điều chỉnh linh hoạt theo điều kiện thực tế (chậu cây, ánh sáng, hệ thống sưởi, mùa trong năm), giữ cho thói quen thật đơn giản (hai ngày kiểm tra cố định mỗi tuần nếu họ muốn), và chỉ ra những cách bố trí gây chết cây nhiều nhất: chậu không có lỗ thoát nước, cây mọng nước đặt ở góc tối, cây dương xỉ bị khô kiệt trong chậu đất nung (terracotta).

## Các tệp trong kỹ năng này

- `scripts/watering_planner.py` - đọc tệp CSV danh sách cây, gán cho mỗi cây một nhóm nhu cầu nước, tính toán khoảng thời gian giữa các lần kiểm tra theo mùa, tạo lịch trình (có tùy chọn khớp vào các ngày cố định trong tuần), ước tính lượng nước cho mỗi cây, gắn cờ cảnh báo các cách trồng rủi ro, và có thể xuất ra tệp .ics (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/plant-water-profiles.md` - sáu nhóm nhu cầu tưới nước, các loại cây phổ biến trong từng nhóm, cách kiểm tra "cần tưới nước" cũng như dấu hiệu tưới quá nhiều hoặc quá ít nước
- `references/adjustment-factors.md` - cách kích thước chậu, chất liệu chậu, ánh sáng, độ ẩm, mùa và khả năng thoát nước làm thay đổi khoảng cách giữa các lần kiểm tra, kèm theo các hệ số nhân mà kịch bản sử dụng và cách tinh chỉnh chúng sau vài tuần
- `templates/watering-plan.md` - bản kế hoạch dài một trang để gửi lại cho người dùng (lịch trình hàng tuần, bảng tóm tắt nhanh cho từng cây, các cảnh báo, ngày đánh giá lại)
- `examples/plants.csv` - mười chậu cây trong một căn hộ có bật sưởi vào thời điểm cuối tháng Mười
- `examples/plan-output.md` - kết quả đầu ra thực tế của bộ lập lịch cho danh sách cây đó

## Quy trình thực hiện

### 1. Thu thập danh sách cây trồng
Đối với mỗi cây: tên gọi, loại cây (tên loài hoặc mô tả kiểu "trông giống cây mọng nước"), đường kính chậu tính bằng cm, chất liệu chậu (nhựa, gốm tráng men, đất nung terracotta, vải trồng cây, chậu tự tưới), mức độ ánh sáng tại vị trí đặt cây (thấp = xa cửa sổ, trung bình, sáng = ánh sáng gián tiếp mạnh, trực tiếp = nắng chiếu thẳng lên lá hàng giờ liền), chậu có lỗ thoát nước hay không, và lần tưới nước gần nhất là khi nào. Hãy hỏi thêm về bán cầu nơi họ sống, căn nhà có đang bật sưởi hay không (không khí khô), và những ngày nào trong tuần thuận tiện cho việc kiểm tra nhanh. Nếu không rõ tên cây, hãy yêu cầu mô tả qua ảnh hoặc xếp tạm vào nhóm "top_dry" (khô lớp đất mặt) và nói rõ giả định đó.

### 2. Chạy bộ lập lịch
```
python3 scripts/watering_planner.py examples/plants.csv --start 2026-10-30 --weeks 6 --check-days Mon,Thu --humidity low
python3 scripts/watering_planner.py plants.csv --hemisphere south --weeks 8 --ics plants.ics
python3 scripts/watering_planner.py plants.csv --json
python3 scripts/watering_planner.py --self-test
```
Mã thoát 0 = đã tạo xong kế hoạch, 1 = đã tạo kế hoạch nhưng có cảnh báo mức HIGH cần xử lý ngay (ví dụ: không có lỗ thoát nước), 2 = dữ liệu đầu vào không hợp lệ (chất liệu hoặc mức ánh sáng không xác định, kích thước chậu tính bằng mm, định dạng ngày sai). Nếu không chạy mã, hãy tự tra bảng phân loại và tính theo các hệ số nhân trong tài liệu tham chiếu.

### 3. Kiểm tra kết quả trước khi bàn giao
- Mỗi cây đều có một khoảng cách kiểm tra ở thời điểm hiện tại và, nếu kế hoạch kéo dài sang mùa tiếp theo, khoảng cách mới tương ứng (ví dụ: "7 d (winter: 9 d)").
- Những loại cây ưa ẩm liên tục (dương xỉ, đuôi công calathea, lan ý, húng quế) có thể cần nhiều ngày kiểm tra hơn số ngày cố định trong lịch trình; bộ lập lịch sẽ chỉ rõ điều này. Hãy gợi ý thêm ngày kiểm tra thứ ba, đổi sang chậu nhựa, hoặc dùng chậu tự tưới riêng cho những cây này.
- "Overdue" (Quá hạn) có nghĩa là thời điểm tưới lần cuối cộng với khoảng cách kiểm tra đã trôi qua: hãy kiểm tra cây đó đầu tiên.
- Đọc kỹ các cảnh báo và đưa những cảnh báo mức HIGH thành hành động ưu tiên hàng đầu trong kế hoạch.

### 4. Bàn giao kết quả
Điền vào `templates/watering-plan.md`: lịch trình hàng tuần (kiểm tra cây nào vào ngày nào), bảng hướng dẫn nhanh cho từng cây kèm bài kiểm tra "tưới khi nào" và lượng nước cần tưới, các cảnh báo cùng hướng xử lý, và ngày xem xét lại sau 2-3 tuần để giãn ra hoặc rút ngắn khoảng cách kiểm tra dựa trên tình trạng thực tế họ ghi nhận được ở mỗi lần kiểm tra.

## Ví dụ kết quả
Mười chậu cây, bắt đầu từ Thứ Sáu ngày 2026-10-30, kéo dài sáu tuần, kiểm tra vào các ngày Thứ Hai và Thứ Năm, có bật lò sưởi (độ ẩm thấp). Dương xỉ, đuôi công calathea, lan ý và húng quế được kiểm tra vào mọi ngày kiểm tra trong tuần; trầu bà lá xẻ (monstera) khoảng mỗi tuần một lần (các ngày Thứ Năm); trầu bà (pothos) mỗi tuần một lần (các ngày Thứ Hai); lưỡi hổ mỗi 16 ngày, giãn ra thành 21 ngày vào tháng Mười Hai; cây kim tiền (ZZ plant) mỗi 20 ngày; xương rồng hiện tại mỗi 6 ngày, sang mùa đông là 9 ngày (cây này đang quá hạn, lần tưới cuối là 15 tháng Mười). Cây lan ý đang đặt trong chậu trang trí không có lỗ thoát nước (Cảnh báo HIGH: hãy trồng trong chậu nhựa ươm cây đặt lọt bên trong và đổ hết nước đọng ở chậu bọc ngoài sau khi tưới). Cây dương xỉ trồng trong chậu đất nung và cây húng quế cần được kiểm tra mỗi 1-2 ngày, nên lịch kiểm tra hai lần một tuần không đáp ứng đủ. Xem chi tiết tại `examples/plan-output.md`.

## Quy tắc

- Không bao giờ chỉ định tưới nước cứng nhắc theo lịch cố định mà không kiểm tra trước; luôn gắn mỗi lần kiểm tra với bài thử "tưới khi nào" của từng loại cây.
- Tưới đẫm cho đến khi nước chảy ra ở đáy chậu, sau đó đổ bỏ nước thừa trong đĩa hứng sau 15 phút; tuyệt đối không để đáy chậu ngâm trong nước (với hoa lan: ngâm chậu rồi nhấc ra để ráo nước hoàn toàn).
- Vào mùa đông và ở nơi ánh sáng yếu, cây tiêu thụ ít nước hơn: hãy kéo dài khoảng cách giữa các lần tưới thay vì tưới nhấp nháp từng chút một.
- Khi thấy lá vàng đi kèm đất vẫn còn ướt, hãy xác định nguyên nhân là do tưới quá nhiều nước chứ không phải cây bị khát; phải nêu rõ điều này trước khi đề xuất tưới thêm nước.
- Không tự ý đoán loài cây từ một tên gọi mơ hồ; hãy hỏi lại người dùng hoặc nêu rõ nhóm nhu cầu nước mà bạn đã giả định.
- Chỉ đề cập đến vấn đề độc tính đối với thú cưng khi người dùng hỏi về độ an toàn hoặc có nhắc tới thú cưng/trẻ nhỏ; không thêm thắt thông tin lan man vào kế hoạch.
FILE:references/plant-water-profiles.md
# Các nhóm nhu cầu tưới nước

| Nhóm | Các loại cây điển hình | Tưới khi | Khoảng cách kiểm tra cơ sở* | Độ giãn cách vào mùa đông |
|---|---|---|---|---|
| succulent | xương rồng, nha đam (lô hội), sen đá (echeveria), cây ngọc bích (jade), móng rồng (haworthia), chuỗi ngọc (string of pearls) | đất khô hoàn toàn từ trên xuống đáy: cắm một chiếc đũa gỗ chạm đáy chậu khi rút ra thấy sạch và khô ráo; nhấc chậu thấy nhẹ hẳn | 10 ngày | x2.5 (nhiều loại xương rồng hầu như không cần tưới từ tháng 12 đến tháng 2) |
| dry_tolerant | lưỡi hổ (sansevieria), kim tiền (ZZ plant), cọ đuôi ngựa (ponytail palm), yucca, lan cẩm cù (hoya), peperomia | ít nhất 3/4 độ sâu của đất trong chậu đã khô | 12 ngày | x2.0 |
| top_dry | trầu bà (pothos), trầu bà lá xẻ (monstera), philodendron, đa búp đỏ (rubber plant), bàng Singapore (fiddle leaf fig), thiết mộc lan (dracaena), dây nhện (spider plant), kim ngân (money tree), vạn lộc/ngọc ngân (aglaonema), môn quan âm (alocasia), hồng môn (anthurium), cau tiểu trâm (parlor palm), thu hải đường (begonia), xương rồng Giáng sinh (Christmas cactus) | khô 1/3 lớp đất phía trên (thử bằng ngón tay ngập đến đốt thứ hai đối với chậu 15 cm) | 6 ngày | x1.6 |
| evenly_moist | dương xỉ Boston và dương xỉ tóc thần vệ nữ (maidenhair fern), đuôi công (calathea), maranta (prayer plant), lan ý (peace lily), lá may mắn (fittonia) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để đất khô hoàn toàn | 3 ngày | x1.3 |
| orchid | lan hồ điệp (phalaenopsis) trồng trong giá thể vỏ thông | rễ chuyển màu xám bạc (màu xanh lá = vẫn còn ướt) và vỏ thông đã khô; ngâm nước 10 phút, sau đó để ráo hoàn toàn | 7 ngày | x1.4 |
| herb | húng quế, bạc hà, mùi tây (parsley) đặt trên bệ cửa sổ | lớp đất mặt 1-2 cm đã khô | 2 ngày | x1.2 |

*Mức cơ sở = mùa xuân, chậu nhựa 15 cm có lỗ thoát nước, ánh sáng trung bình, độ ẩm bình thường. Mùa thu nằm ở mức giữa mùa xuân và mùa đông; mùa hè nhân với hệ số x0.85. Xem `adjustment-factors.md`.

## Cách kiểm tra độ ẩm (30 giây cho mỗi cây)
1. **Thử bằng ngón tay:** ấn ngón tay vào đất đến độ sâu theo quy tắc của nhóm cây. Đất khô và tơi rời = khô; mát và dính vào tay = còn ẩm.
2. **Ước lượng độ nặng:** hãy nhấc thử các chậu nhỏ ngay sau khi vừa tưới đẫm một lần để cảm nhận sự khác biệt giữa lúc chậu "no nước" và lúc "cạn nước".
3. **Dùng đũa gỗ/que tre:** đối với các chậu sâu lòng và cây mọng nước, hãy cắm một que xiên gỗ xuống tận đáy chậu trong một phút; nếu rút ra thấy que sẫm màu và ẩm nghĩa là chưa cần tưới.
4. **Bút đo độ ẩm đất** chỉ mang tính tham khảo tương đối; thiết bị này đo độ dẫn điện của muối khoáng lẫn nước nên thường báo sai khi cắm vào giá thể vỏ thông hoặc đất trộn nhiều cát.

## Các dấu hiệu nhận biết
| Tưới quá nhiều nước (phổ biến hơn) | Tưới thiếu nước |
|---|---|
| lá tầng dưới ngả vàng trong khi đất vẫn ướt, gốc thân mềm nhũn hoặc chuyển màu đen, rễ bị thối nhũn, xuất hiện ruồi nấm (fungus gnats), đất có mùi chua ủng | mép lá khô giòn chuyển màu nâu, lá rũ xuống nhưng tươi tỉnh lại chỉ sau vài giờ được tưới nước, đất co lại tách rời khỏi thành chậu, nhấc chậu thấy rất nhẹ |
| Cách khắc phục: ngừng tưới ngay, kiểm tra bộ rễ, thay sang giá thể đất khô mới nếu rễ đã bị thối, kéo dài khoảng cách giữa các lần kiểm tra | Cách khắc phục: tưới đẫm hoàn toàn (ngâm đáy chậu trong nước 20-30 phút nếu đất bị trơ không ngấm nước), rút ngắn khoảng cách kiểm tra lại một chút |

Cây bị rũ lá trong khi đất vẫn **ướt** có nghĩa là bộ rễ đã bị tổn thương: tuyệt đối không tưới thêm nước.

## Kỹ thuật tưới nước
- Tưới từ từ khắp bề mặt đất cho đến khi nước chảy ra ở lỗ đáy chậu; chờ một lát, sau đó đổ bỏ nước đọng trong đĩa hứng sau khoảng 15 phút.
- Dùng nước ở nhiệt độ phòng. Cây đuôi công (calathea) và một số loài dương xỉ dễ bị cháy nâu đầu lá nếu tưới bằng nước cứng hoặc nước đã làm mềm bằng muối; dùng nước mưa hoặc nước lọc sẽ tốt hơn.
- Tưới thẩm thấu ngược từ đáy (ngâm đáy chậu trong khay nước khoảng 20-30 phút) rất hiệu quả với các chậu nhỏ và đất bị khô co rút lại; thỉnh thoảng hãy tưới từ trên xuống để rửa trôi muối khoáng tích tụ.
- Chậu tự tưới: châm thêm nước vào bình chứa khi đã cạn và thỉnh thoảng cứ để bình chứa cạn khô trong một hoặc hai ngày để rễ cây được hô hấp; không dùng loại chậu này cho cây mọng nước.
FILE:references/adjustment-factors.md
# Các hệ số điều chỉnh

Khoảng cách kiểm tra = số ngày cơ sở (theo nhóm cây) x kích thước chậu x chất liệu chậu x ánh sáng x độ ẩm x mùa, được làm tròn và giới hạn trong khoảng 1..45 ngày. Dưới đây là các hệ số nhân được sử dụng trong `scripts/watering_planner.py`.

| Yếu tố | Giá trị | Hệ số nhân | Lý do |
|---|---|---|---|
| Đường kính chậu | dưới 10 cm | 0.6 | ít đất, khô chỉ sau vài ngày |
| | 10-13 cm | 0.8 | |
| | 14-19 cm | 1.0 | mức tham chiếu chuẩn |
| | 20-27 cm | 1.3 | nhiều đất hơn nên giữ nước lâu hơn |
| | từ 28 cm trở lên | 1.6 | chậu lớn vẫn còn ướt ở đáy rất lâu sau khi bề mặt đã khô: cần kiểm tra sâu xuống dưới |
| Chất liệu | nhựa, tráng men, gốm sứ, kim loại | 1.0 | thành chậu không thoát hơi nước |
| | đất nung (terracotta) / đất sét không tráng men | 0.75 | thành chậu thấm hút và làm bay hơi nước |
| | túi vải trồng cây (fabric grow bag) | 0.7 | khô nhanh từ mọi phía |
| | chậu tự tưới | 1.5 | bình chứa cấp ẩm dần cho đất; hãy kiểm tra bình chứa nước thay vì mặt đất |
| Ánh sáng | thấp (cách cửa sổ trên 2 m, phòng hướng Bắc) | 1.4 | cây ít phát triển, tiêu thụ ít nước |
| | trung bình | 1.0 | |
| | ánh sáng gián tiếp mạnh | 0.85 | |
| | nắng chiếu trực tiếp lên lá | 0.7 | |
| Độ ẩm | thấp (đang bật sưởi, dưới ~35 %) | 0.85 | đất và lá mất nước nhanh hơn |
| | bình thường | 1.0 | |
| | cao (phòng tắm, có máy tạo ẩm) | 1.15 | |
| Mùa | mùa xuân | 1.0 | |
| | mùa hè | 0.85 | |
| | mùa thu | mức giữa của mùa xuân và mùa đông | đà tăng trưởng chậm lại khi ngày ngắn đi |
| | mùa đông | hệ số giãn cách mùa đông theo từng nhóm cây (từ x1.2 đến x2.5) | |

Phân chia mùa: ở Bắc bán cầu, tháng 12-tháng 2 là mùa đông, tháng 3-tháng 5 là mùa xuân, tháng 6-tháng 8 là mùa hè, tháng 9-tháng 11 là mùa thu; ở Nam bán cầu lệch đi sáu tháng. Nếu đặt cây ngay cạnh lò sưởi hoặc trong phòng kính có sưởi ấm, hãy chọn mức độ ẩm "low" (thấp) ngay cả vào mùa đông.

## Khả năng thoát nước
Chậu không có lỗ thoát nước là nguyên nhân đơn lẻ lớn nhất gây thối rễ. Cách tốt nhất: giữ nguyên cây trong chậu nhựa ươm cây đặt lọt bên trong chậu trang trí và đổ hết nước đọng ở chậu ngoài sau khi tưới 15 phút. Nếu bắt buộc phải trồng trực tiếp trong chậu không có lỗ, chỉ tưới khoảng một nửa lượng nước thông thường và nghiêng nhẹ chậu để kiểm tra xem có nước đọng dưới đáy không. Bộ lập lịch sẽ gắn cờ trường hợp này ở mức cảnh báo HIGH.

## Lượng nước tưới
Khoảng 15-25 % thể tích chậu, tưới cho đến khi thấy nước thoát ra ở lỗ đáy. Bộ lập lịch ước tính thể tích chậu từ đường kính (giả định chậu thành đứng có chiều cao bằng khoảng 0.9 lần chiều rộng), vì vậy chậu 15 cm cần khoảng 450 ml nước, chậu 24 cm cần khoảng 1.75 lít. Cây mọng nước và cây chịu hạn cũng cần được tưới đẫm mỗi lần như vậy, chỉ là tần suất thưa hơn.

## Tinh chỉnh sau hai hoặc ba tuần
- Đất vẫn còn ẩm ở hầu hết các lần kiểm tra: hãy kéo dài khoảng cách kiểm tra của cây đó thêm 25-50 % (hoặc chuyển thông số ánh sáng sang "low" nếu cây đặt xa cửa sổ).
- Đất khô cong ở mọi lần kiểm tra và lá cây bị rũ xuống: rút ngắn khoảng cách kiểm tra đi 25 %.
- Hãy cập nhật trực tiếp các thay đổi này vào tệp CSV (ví dụ: đổi nhóm cây hoặc mức ánh sáng) rồi chạy lại công cụ, thay vì cố ghi nhớ các trường hợp ngoại lệ trong đầu.
- Chạy lại kế hoạch khi bắt đầu bật hoặc tắt hệ thống sưởi, khi bạn thay chậu mới (chậu lớn hơn = khoảng cách dài hơn), hoặc khi đổi vị trí đặt cây.

## Các trường hợp nằm ngoài quy tắc tính khoảng cách
- Cây mới thay chậu: tưới đẫm một lần ngay sau khi thay chậu, sau đó chờ đến khi đất đạt đúng điều kiện trong quy tắc mới tưới tiếp.
- Các loại củ ngủ đông và xương rồng trong giai đoạn nghỉ đông lạnh giá (5-12 C): hầu như không cần tưới nước; chỉ cần kiểm tra mỗi tháng một lần là đủ.
- Bón phân: chỉ thực hiện vào mùa xuân và mùa hè, pha loãng bằng một nửa liều lượng khuyến cáo, bón vào đúng ngày tưới nước.
FILE:templates/watering-plan.md
# Kế hoạch chăm sóc cây: <nhà / phòng>

**Giai đoạn:** <2026-10-30 đến 2026-12-10>   **Ngày kiểm tra:** <Thứ Hai và Thứ Năm, 5-10 phút>
**Điều kiện:** <Bắc bán cầu, đang bật sưởi (không khí khô)>   **Đánh giá lại vào:** <2026-11-16>

## Việc cần làm ngay
- <Lan ý (Peace lily): không có lỗ thoát nước - hãy chuyển cây sang chậu nhựa ươm đặt lọt bên trong chậu bọc ngoài; đổ bỏ nước đọng ở chậu bọc ngoài sau khi tưới 15 phút>
- <Spike (xương rồng): đã quá hạn - cần kiểm tra ngay vào Thứ Hai>

## Lịch trình hàng tuần
| Ngày | Kiểm tra các cây sau |
|---|---|
| <Thứ Hai> | <dương xỉ, đuôi công calathea, lan ý, húng quế, hoa lan, trầu bà pothos, xương rồng (mỗi Thứ Hai cách tuần trong tháng Mười Hai)> |
| <Thứ Năm> | <dương xỉ, đuôi công calathea, lan ý, húng quế, hoa lan, trầu bà lá xẻ monstera; lưỡi hổ mỗi 2-3 tuần> |

## Bảng hướng dẫn nhanh
| Cây | Tưới khi | Lượng nước ước tính | Ghi chú |
|---|---|---|---|
| <Monty (monstera)> | <khô 1/3 lớp đất mặt> | <~1.75 l cho đến khi thoát nước> | <cửa sổ hướng Đông nhiều sáng> |
| <Sansa (snake plant)> | <khô 3/4 độ sâu đất> | <~450 ml> | <Tháng Mười Hai: mỗi 3 tuần> |

## Cách kiểm tra
- Ấn ngón tay vào đất đến độ sâu ghi trong cột "Tưới khi"; chỉ tưới nếu đất đã khô đến độ sâu đó.
- Tưới cho đến khi nước chảy ra ở đáy chậu, đổ hết nước trong đĩa hứng sau 15 phút.
- Đất ướt + lá vàng = thừa nước: bỏ qua lượt tưới và báo lại cho tôi.

## Cảnh báo
- <Fernando (dương xỉ Boston) trồng trong chậu đất nung bị khô chỉ sau 2 ngày khi bật sưởi: hãy thêm một buổi kiểm tra vào Thứ Bảy hoặc chuyển sang chậu nhựa>
- <Húng quế cần được kiểm tra hàng ngày trên bệ cửa sổ đầy nắng; hãy tưới ngâm đáy chậu mỗi khi bạn đi ngang qua>

## Khi đánh giá lại
Đối với mỗi cây, hãy ghi chú xem đất thường vẫn còn ẩm (cần giãn cách dài hơn) hay đã khô kiệt (cần rút ngắn lại) vào các buổi kiểm tra; tôi sẽ cập nhật lại kế hoạch.
FILE:examples/plants.csv
name,plant,pot_cm,pot_material,light,drainage,last_watered,notes
Monty,monstera,24,plastic,bright,yes,2026-10-28,living room by the east window
Pothos shelf,pothos,14,glazed,medium,yes,2026-10-27,trailing from the bookshelf
Sansa,snake plant,17,terracotta,low,yes,2026-10-20,hallway corner
Zed,zz plant,20,plastic,medium,yes,,new from the shop
Fernando,boston fern,20,terracotta,bright,yes,2026-10-29,bathroom shelf
Cal,calathea,15,plastic,medium,yes,2026-10-28,bedroom
Spike,cactus,10,terracotta,direct,yes,2026-10-15,south windowsill
Peace lily,peace lily,18,glazed,low,no,2026-10-26,decorative pot with no hole
Orchid,phalaenopsis,12,plastic,bright,yes,2026-10-25,kitchen window
Basil,basil,13,terracotta,direct,yes,2026-10-29,kitchen windowsill
FILE:examples/plan-output.md
# Kế hoạch tưới cây trong nhà: 2026-10-30 đến 2026-12-10

Bán cầu: Bắc   Mùa lúc bắt đầu: mùa thu   Độ ẩm: thấp   Ngày kiểm tra: Thứ Hai, Thứ Năm   Số lượng cây: 10

Kiểm tra không đồng nghĩa với tưới nước: hãy chạm tay cảm nhận độ ẩm của đất và chỉ tưới khi quy tắc của cây báo đã đến lúc.

## Danh sách cây

| Cây | Nhóm | Chậu | Ánh sáng | Kiểm tra mỗi | Tưới khi | Lượng nước ước tính | Số lần kiểm tra |
|---|---|---|---|---|---|---|---|
| Monty (monstera) | top_dry | nhựa 24 cm | sáng | 7 d (winter: 9 d) | khô 1/3 lớp đất phía trên (khoảng 3-5 cm đối với chậu 15 cm) | ~1750 ml (cho đến khi thoát nước) | 6 |
| Pothos shelf (pothos) | top_dry | tráng men 14 cm | trung bình | 7 d (winter: 8 d) | khô 1/3 lớp đất phía trên (khoảng 3-5 cm đối với chậu 15 cm) | ~350 ml (cho đến khi thoát nước) | 6 |
| Sansa (snake plant) | dry_tolerant | đất nung 17 cm | thấp | 16 d (winter: 21 d) | đất khô ít nhất 3/4 độ sâu từ trên xuống | ~450 ml (cho đến khi thoát nước) | 3 |
| Zed (zz plant) | dry_tolerant | nhựa 20 cm | trung bình | 20 d | đất khô ít nhất 3/4 độ sâu từ trên xuống | ~750 ml (cho đến khi thoát nước) | 2 |
| Fernando (boston fern) | evenly_moist | đất nung 20 cm | sáng | 2 d (winter: 3 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~1000 ml (cho đến khi thoát nước) | 12 |
| Cal (calathea) | evenly_moist | nhựa 15 cm | trung bình | 3 d (winter: 3 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~450 ml (cho đến khi thoát nước) | 12 |
| Spike (cactus) (quá hạn: kiểm tra ngay ngày đầu tiên) | succulent | đất nung 10 cm | trực tiếp | 6 d (winter: 9 d) | đất khô hoàn toàn đến tận đáy (rút đũa gỗ ra thấy sạch và khô) | ~100 ml (cho đến khi thoát nước) | 6 |
| Peace lily (peace lily) | evenly_moist | tráng men 18 cm, KHÔNG lỗ | thấp | 4 d (winter: 5 d) | lớp đất mặt 1-2 cm vừa chớm khô; không bao giờ để khô hoàn toàn | ~375 ml, không tưới nhiều hơn | 12 |
| Orchid (phalaenopsis) | orchid | nhựa 12 cm | sáng | 5 d (winter: 6 d) | rễ màu xám bạc và vỏ thông khô; ngâm 10 phút, để ráo hoàn toàn | ngâm 10 phút | 11 |
| Basil (basil) | herb | đất nung 13 cm | trực tiếp | 1 d (winter: 1 d) | lớp đất mặt 1-2 cm đã khô | ~350 ml (cho đến khi thoát nước) | 12 |

## Lịch kiểm tra

| Ngày | Thứ | Mùa | Cây cần kiểm tra |
|---|---|---|---|
| 2026-11-02 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-05 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-09 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-12 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-16 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-19 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-23 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-26 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-30 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-12-03 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-12-07 | Mon | winter | Basil, Cal, Fernando, Peace lily, Pothos shelf, Spike |
| 2026-12-10 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily |

## Cảnh báo

| Mức độ | Cây | Lưu ý |
|---|---|---|
| HIGH | Peace lily | chậu không có lỗ thoát nước: nước đọng ở đáy làm thối rễ. Hãy thay chậu có lỗ, hoặc trồng cây trong chậu nhựa ươm đặt lọt bên trong chậu bọc ngoài và đổ hết nước đọng sau khi tưới 15 phút |
| LOW | Fernando | chậu đất nung làm cây này khô rất nhanh; đổi sang chậu nhựa hoặc chậu gốm tráng men sẽ giúp giữ ẩm đều hơn |
| LOW | Fernando | các ngày kiểm tra cố định khiến một số lượt kiểm tra bị trễ hơn một ngày (ví dụ: đến hạn 2026-11-07, lịch xếp vào 2026-11-09); hãy thêm một ngày kiểm tra trong tuần hoặc sờ thử đất xen kẽ giữa các buổi |
| LOW | Basil | các ngày kiểm tra cố định khiến một số lượt kiểm tra bị trễ hơn một ngày (ví dụ: đến hạn 2026-11-03, lịch xếp vào 2026-11-05); hãy thêm một ngày kiểm tra trong tuần hoặc sờ thử đất xen kẽ giữa các buổi |

Các khoảng thời gian trên giả định rằng cây đã bén rễ ổn định trong giá thể thoát nước tốt. Sau 2-3 tuần, hãy kéo dài khoảng cách kiểm tra nếu thấy đất vẫn còn ẩm ở hầu hết các lần kiểm tra, hoặc rút ngắn lại nếu lúc nào kiểm tra đất cũng đã khô kiệt.
FILE:scripts/watering_planner.py
#!/usr/bin/env python3
"""Houseplant watering schedule planner (Python 3.8+ standard library only).

Reads a CSV of plants (species or watering type, pot diameter, pot material, light,
drainage, optional last watering date) and builds a check calendar: how often to
check each plant in the current season, what "needs water" means for it, roughly how
much to give, and which plants to check on which date. Intervals stretch
automatically as the plan crosses into autumn and winter (or shrink into summer), by
hemisphere. Checks can be snapped to fixed weekdays (e.g. Mon,Thu) so the plan fits a
routine. Optional .ics export for a calendar app.

A check is not a watering: on each check, feel the soil and water only if the plant's
rule says so. The intervals are starting points; adjust them after two or three weeks
of real checks (see references/adjustment-factors.md).

Usage:
  python3 watering_planner.py plants.csv [--start 2026-10-30] [--weeks 4]
        [--hemisphere north|south] [--humidity low|normal|high]
        [--check-days Mon,Thu] [--ics plan.ics] [--json]
  python3 watering_planner.py --self-test
plants.csv columns: name, plant (species or type), pot_cm, pot_material
(plastic|glazed|ceramic|terracotta|fabric|self-watering), light (low|medium|bright|direct),
drainage (yes|no), last_watered (YYYY-MM-DD, optional), notes (optional).
Types: succulent, dry_tolerant, top_dry, evenly_moist, orchid, herb.
Exit codes: 0 = plan written, 1 = plan written with HIGH warnings (e.g. no drainage), 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, math, sys

PROFILES = {
    #               base days (spring, 15 cm plastic, medium light), winter factor, rule, share of pot volume per watering
    "succulent":    (10, 2.5, "soil dry all the way down (chopstick comes out clean and dry)", 0.15),
    "dry_tolerant": (12, 2.0, "soil dry at least 3/4 of the way down", 0.15),
    "top_dry":      (6, 1.6, "top third of the soil dry (about 3-5 cm in a 15 cm pot)", 0.20),
    "evenly_moist": (3, 1.3, "top 1-2 cm just dry; never let it dry out fully", 0.20),
    "orchid":       (7, 1.4, "roots silvery-grey and bark dry; soak 10 min, drain fully", 0.0),
    "herb":         (2, 1.2, "top 1-2 cm dry", 0.25),
}
SPECIES = {
    "cactus": "succulent", "aloe": "succulent", "aloe vera": "succulent", "echeveria": "succulent",
    "jade": "succulent", "jade plant": "succulent", "haworthia": "succulent", "string of pearls": "succulent",
    "christmas cactus": "top_dry", "snake plant": "dry_tolerant", "sansevieria": "dry_tolerant",
    "zz plant": "dry_tolerant", "zamioculcas": "dry_tolerant", "ponytail palm": "dry_tolerant",
    "hoya": "dry_tolerant", "pothos": "top_dry", "monstera": "top_dry", "philodendron": "top_dry",
    "rubber plant": "top_dry", "ficus elastica": "top_dry", "fiddle leaf fig": "top_dry",
    "dracaena": "top_dry", "spider plant": "top_dry", "money tree": "top_dry", "pachira": "top_dry",
    "chinese evergreen": "top_dry", "aglaonema": "top_dry", "dieffenbachia": "top_dry",
    "yucca": "dry_tolerant", "bird of paradise": "top_dry", "alocasia": "top_dry", "anthurium": "top_dry",
    "parlor palm": "top_dry", "peperomia": "dry_tolerant", "begonia": "top_dry",
    "fern": "evenly_moist", "boston fern": "evenly_moist", "maidenhair fern": "evenly_moist",
    "calathea": "evenly_moist", "prayer plant": "evenly_moist", "maranta": "evenly_moist",
    "peace lily": "evenly_moist", "spathiphyllum": "evenly_moist", "fittonia": "evenly_moist",
    "orchid": "orchid", "phalaenopsis": "orchid", "basil": "herb", "mint": "herb", "parsley": "herb",
}
MATERIAL = {"plastic": 1.0, "glazed": 1.0, "ceramic": 1.0, "metal": 1.0, "terracotta": 0.75,
            "clay": 0.75, "unglazed": 0.75, "fabric": 0.7, "self-watering": 1.5}
LIGHT = {"low": 1.4, "medium": 1.0, "bright": 0.85, "direct": 0.7}
HUMIDITY = {"low": 0.85, "normal": 1.0, "high": 1.15}
SEASON_FACTOR = {"spring": 1.0, "summer": 0.85}
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]


class InputError(Exception):
    pass


def season_of(d, hemisphere):
    m = d.month if hemisphere == "north" else (d.month + 5) % 12 + 1
    return {12: "winter", 1: "winter", 2: "winter", 3: "spring", 4: "spring", 5: "spring",
            6: "summer", 7: "summer", 8: "summer"}.get(m, "autumn")


def pot_factor(cm):
    if cm < 10:
        return 0.6
    if cm < 14:
        return 0.8
    if cm < 20:
        return 1.0
    if cm < 28:
        return 1.3
    return 1.6


def pot_litres(cm):
    # straight-sided pot about 0.9x as tall as wide, minus ~10 % for the rim gap
    r = cm / 2.0
    return math.pi * r * r * (0.9 * cm) * 0.9 / 1000.0


def interval_days(p, season, humidity):
    base, winter, _, _ = PROFILES[p["type"]]
    # autumn sits halfway between spring and the plant's winter slow-down
    sf = {"winter": winter, "autumn": (1 + winter) / 2}.get(season) or SEASON_FACTOR[season]
    days = base * pot_factor(p["pot_cm"]) * MATERIAL[p["material"]] * LIGHT[p["light"]] * HUMIDITY[humidity] * sf
    return max(1, min(45, int(round(days))))


def parse_date(s, what):
    try:
        return dt.date.fromisoformat(s.strip())
    except ValueError:
        raise InputError(f"bad date '{s}' for {what} (use YYYY-MM-DD)")


def load_plants(text):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("plants CSV has no rows")
    cols = {c.strip().lower(): c for c in rows[0] if c}
    if "name" not in cols:
        raise InputError("plants CSV needs at least a 'name' column (plus plant, pot_cm, pot_material, light, drainage)")
    plants, warnings = [], []
    for i, r in enumerate(rows, 2):
        g = lambda k, d="": ((r.get(cols[k]) or "").strip() if k in cols else "") or d
        name = g("name")
        if not name:
            raise InputError(f"row {i}: empty name")
        kind = g("plant", g("type", "")).lower()
        if kind in PROFILES:
            ptype = kind
        elif kind in SPECIES:
            ptype = SPECIES[kind]
        else:
            ptype = next((v for k, v in SPECIES.items() if k in kind), None) if kind else None
            if ptype is None:
                ptype = "top_dry"
                warnings.append(("MEDIUM", name, f"unknown plant '{kind or '?'}': using type top_dry; set the plant column to one of {', '.join(PROFILES)}"))
        try:
            pot = float(g("pot_cm", "0") or 0)
        except ValueError:
            raise InputError(f"row {i} ({name}): pot_cm must be a number of centimetres")
        if pot <= 0:
            pot = 15.0
            warnings.append(("LOW", name, "no pot size given: assumed 15 cm"))
        if pot > 80:
            raise InputError(f"row {i} ({name}): pot_cm {pot} looks wrong (centimetres, not millimetres?)")
        mat = g("pot_material", "plastic").lower()
        if mat not in MATERIAL:
            raise InputError(f"row {i} ({name}): pot_material '{mat}' (use {', '.join(MATERIAL)})")
        light = g("light", "medium").lower().replace("bright indirect", "bright").replace("full sun", "direct")
        if light not in LIGHT:
            raise InputError(f"row {i} ({name}): light '{light}' (use low, medium, bright, direct)")
        drainage = g("drainage", "yes").lower() not in ("no", "n", "false", "0")
        last = parse_date(g("last_watered"), f"{name} last_watered") if g("last_watered") else None
        p = {"name": name, "plant": kind or ptype, "type": ptype, "pot_cm": pot, "material": mat,
             "light": light, "drainage": drainage, "last_watered": last, "notes": g("notes")}
        plants.append(p)
        if not drainage:
            warnings.append(("HIGH", name, "pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering"))
        if ptype == "succulent" and light == "low":
            warnings.append(("MEDIUM", name, "succulent in low light: it will stretch and rot easily; move it to the brightest window and water even less"))
        if ptype == "evenly_moist" and light == "direct":
            warnings.append(("MEDIUM", name, "moisture-loving plant in direct sun: leaves scorch and soil dries within a day; move it out of direct sun"))
        if ptype == "evenly_moist" and mat in ("terracotta", "clay", "unglazed", "fabric"):
            warnings.append(("LOW", name, f"{mat} pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist"))
        if ptype == "orchid" and mat not in ("plastic",):
            warnings.append(("LOW", name, "orchids are easiest in a clear plastic pot with drainage, so you can see the roots"))
        if mat == "self-watering" and ptype in ("succulent", "dry_tolerant", "orchid"):
            warnings.append(("MEDIUM", name, "self-watering pots keep the soil wet; not suited to plants that need to dry out"))
    return plants, warnings


def amount_text(p):
    _, _, _, share = PROFILES[p["type"]]
    if p["type"] == "orchid":
        return "soak 10 min"
    if p["material"] == "self-watering":
        return "refill reservoir"
    ml = pot_litres(p["pot_cm"]) * 1000 * share
    ml = int(round(ml / 50.0) * 50) or 50
    return f"~{ml} ml (until it drains)" if p["drainage"] else f"~{ml // 2} ml, no more"


def snap(due, prev, allowed):
    """Allowed weekday nearest to the due date (ties go earlier), always after prev."""
    if not allowed:
        return due
    best = None
    for off in (0, -1, 1, -2, 2, -3, 3):
        d = due + dt.timedelta(days=off)
        if d > prev and d.weekday() in allowed:
            best = d
            break
    if best is None:
        best = max(due, prev + dt.timedelta(days=1))
        while best.weekday() not in allowed:
            best += dt.timedelta(days=1)
    return best


def plan(plants, start, weeks, hemisphere="north", humidity="normal", check_days=None):
    end = start + dt.timedelta(days=7 * weeks - 1)
    allowed = {DAY_NAMES.index(x) for x in check_days} if check_days else set()
    cal, per_plant, notes = {}, [], []
    for p in plants:
        s0 = season_of(start, hemisphere)
        iv = interval_days(p, s0, humidity)
        if p["last_watered"]:
            due = p["last_watered"] + dt.timedelta(days=iv)
            overdue = due < start
            first = max(due, start)
        else:
            overdue, first = False, start
        d = snap(first, start - dt.timedelta(days=1), allowed) if allowed else first
        dates, prev = [], None
        while d <= end:
            dates.append(d)
            cal.setdefault(d, []).append(p["name"])
            step = interval_days(p, season_of(d, hemisphere), humidity)
            due = d + dt.timedelta(days=step)
            nd = snap(due, d, allowed)
            if allowed and (nd - due).days > max(1, step // 5):
                notes.append((p["name"], due, nd))
            prev, d = d, nd
        later = {season_of(x, hemisphere) for x in dates} - {s0}
        per_plant.append({**p, "interval_now": iv, "season_now": s0,
                          "intervals_later": {s: interval_days(p, s, humidity) for s in sorted(later)},
                          "rule": PROFILES[p["type"]][2], "amount": amount_text(p),
                          "checks": [x.isoformat() for x in dates], "overdue": overdue})
    return {"start": start, "end": end, "hemisphere": hemisphere, "humidity": humidity,
            "check_days": check_days or [], "plants": per_plant,
            "calendar": [(d, sorted(cal[d], key=str.lower)) for d in sorted(cal)], "late_snaps": notes}


def render(res, warnings):
    st, en = res["start"], res["end"]
    out = [f"# Houseplant watering plan: {st.isoformat()} to {en.isoformat()}", "",
           f"Hemisphere: {res['hemisphere']}   Season at start: {season_of(st, res['hemisphere'])}   "
           f"Humidity: {res['humidity']}   Check days: {', '.join(res['check_days']) or 'any day'}   Plants: {len(res['plants'])}", "",
           "A check is not a watering: feel the soil and water only when the rule says so.", "",
           "## Plants", "",
           "| Plant | Type | Pot | Light | Check every | Water when | About | Checks |",
           "|---|---|---|---|---|---|---|---|"]
    for p in res["plants"]:
        iv = f"{p['interval_now']} d"
        if p["intervals_later"]:
            iv += " (" + ", ".join(f"{s}: {v} d" for s, v in p["intervals_later"].items()) + ")"
        pot = f"{p['pot_cm']:g} cm {p['material']}" + ("" if p["drainage"] else ", NO hole")
        flag = " (overdue: check first day)" if p["overdue"] else ""
        out.append(f"| {p['name']} ({p['plant']}){flag} | {p['type']} | {pot} | {p['light']} | {iv} | {p['rule']} | {p['amount']} | {len(p['checks'])} |")
    out += ["", "## Calendar", "", "| Date | Day | Season | Check |", "|---|---|---|---|"]
    for d, names in res["calendar"]:
        out.append(f"| {d.isoformat()} | {DAY_NAMES[d.weekday()]} | {season_of(d, res['hemisphere'])} | {', '.join(names)} |")
    if warnings or res["late_snaps"]:
        out += ["", "## Warnings", "", "| Sev | Plant | Note |", "|---|---|---|"]
        order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
        for sev, name, msg in sorted(warnings, key=lambda w: (order[w[0]], w[1].lower())):
            out.append(f"| {sev} | {name} | {msg} |")
        seen = set()
        for name, due, nd in res["late_snaps"]:
            if name in seen:
                continue
            seen.add(name)
            out.append(f"| LOW | {name} | check days push some checks more than a day late (e.g. due {due.isoformat()}, planned {nd.isoformat()}); add a check day or feel the soil in between |")
    out += ["", "Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry."]
    return "\n".join(out) + "\n"


def to_ics(res, stamp):
    lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//houseplant-watering-schedule-planner//EN", "CALSCALE:GREGORIAN"]
    for d, names in res["calendar"]:
        nd = d + dt.timedelta(days=1)
        lines += ["BEGIN:VEVENT", f"UID:plants-{d.strftime('%Y%m%d')}@watering-planner", f"DTSTAMP:{stamp}",
                  f"DTSTART;VALUE=DATE:{d.strftime('%Y%m%d')}", f"DTEND;VALUE=DATE:{nd.strftime('%Y%m%d')}",
                  "SUMMARY:" + ics_escape("Check plants: " + ", ".join(names)), "TRANSP:TRANSPARENT", "END:VEVENT"]
    lines.append("END:VCALENDAR")
    return "\r\n".join(fold(l) for l in lines) + "\r\n"


def ics_escape(s):
    return s.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")


def fold(line):
    b = line.encode("utf-8")
    if len(b) <= 75:
        return line
    parts, cur = [], b""
    for ch in line:
        e = ch.encode("utf-8")
        if len(cur) + len(e) > (75 if not parts else 74):
            parts.append(cur.decode("utf-8")); cur = b""
        cur += e
    parts.append(cur.decode("utf-8"))
    return "\r\n ".join(parts)


def self_test():
    H = "name,plant,pot_cm,pot_material,light,drainage,last_watered\n"
    def run(rows, **kw):
        plants, warns = load_plants(H + rows)
        return plan(plants, kw.pop("start", dt.date(2026, 4, 6)), kw.pop("weeks", 4), **kw), warns
    cases = []
    def case(name, fn):
        try:
            ok = bool(fn())
            err = ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))

    case("succulent checked less often than fern", lambda: run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][1]["interval_now"])
    case("base interval top_dry 15 cm plastic medium spring = 6", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] == 6)
    case("small pot dries faster", lambda: run("A,pothos,9,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("big pot lasts longer", lambda: run("A,pothos,30,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > 6)
    case("terracotta dries faster than plastic", lambda: run("A,pothos,15,terracotta,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("low light lengthens, direct sun shortens", lambda: run("A,pothos,15,plastic,low,yes,\n")[0]["plants"][0]["interval_now"] > 6 > run("A,pothos,15,plastic,direct,yes,\n")[0]["plants"][0]["interval_now"])
    case("winter stretches succulent 2.5x", lambda: run("A,cactus,15,plastic,medium,yes,\n", start=dt.date(2026, 1, 5))[0]["plants"][0]["interval_now"] == 25)
    case("summer shortens", lambda: run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 7, 6))[0]["plants"][0]["interval_now"] == 5)
    case("southern hemisphere: July is winter", lambda: season_of(dt.date(2026, 7, 1), "south") == "winter" and season_of(dt.date(2026, 1, 1), "south") == "summer")
    case("season boundaries north", lambda: [season_of(dt.date(2026, m, 15), "north") for m in (2, 3, 5, 6, 8, 9, 11, 12)] == ["winter", "spring", "spring", "summer", "summer", "autumn", "autumn", "winter"])
    case("interval grows across autumn->winter in plan", lambda: (lambda r: r["plants"][0]["intervals_later"].get("winter", 0) > r["plants"][0]["interval_now"])(run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 11, 16), weeks=4)[0]))
    case("checks spaced by interval", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["checks"][:3] == ["2026-04-06", "2026-04-12", "2026-04-18"])
    case("last_watered sets first check", lambda: run("A,pothos,15,plastic,medium,yes,2026-04-04\n")[0]["plants"][0]["checks"][0] == "2026-04-10")
    case("overdue goes to start", lambda: (lambda p: p["overdue"] and p["checks"][0] == "2026-04-06")(run("A,pothos,15,plastic,medium,yes,2026-03-20\n")[0]["plants"][0]))
    case("snap to check days (Mon,Thu)", lambda: all(dt.date.fromisoformat(x).weekday() in (0, 3) for x in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["calendar"] and [x for p in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["plants"] for x in p["checks"]]))
    case("snap to nearest check day (Sun -> Mon)", lambda: snap(dt.date(2026, 4, 12), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 13))
    case("snap tie goes earlier (Tue between Mon and Thu? no: Tue -> Mon)", lambda: snap(dt.date(2026, 4, 14), dt.date(2026, 4, 9), {0, 3}) == dt.date(2026, 4, 13))
    case("snap never on or before the previous check", lambda: snap(dt.date(2026, 4, 7), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 9))
    case("autumn between spring and winter", lambda: 6 < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 10, 5))[0]["plants"][0]["interval_now"] < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 12, 7))[0]["plants"][0]["interval_now"])
    case("late snap noted for fast plant", lambda: len(run("A,basil,9,terracotta,direct,yes,\n", check_days=["Mon"])[0]["late_snaps"]) > 0)
    case("no drainage -> HIGH warning and half amount", lambda: (lambda r: any(w[0] == "HIGH" for w in r[1]) and "no more" in r[0]["plants"][0]["amount"])(run("A,pothos,15,glazed,medium,no,\n")))
    case("unknown plant -> top_dry with warning", lambda: (lambda r: r[0]["plants"][0]["type"] == "top_dry" and any("unknown plant" in w[2] for w in r[1]))(run("A,triffid,15,plastic,medium,yes,\n")))
    case("species substring match", lambda: run("A,variegated monstera,15,plastic,medium,yes,\n")[0]["plants"][0]["type"] == "top_dry")
    case("succulent low light warning", lambda: any("stretch" in w[2] for w in run("A,echeveria,10,terracotta,low,yes,\n")[1]))
    case("fern in direct sun warning", lambda: any("scorch" in w[2] for w in run("A,boston fern,20,plastic,direct,yes,\n")[1]))
    case("self-watering succulent warning", lambda: any("self-watering" in w[2] for w in run("A,cactus,15,self-watering,bright,yes,\n")[1]))
    case("orchid amount is soak", lambda: run("A,orchid,12,plastic,bright,yes,\n")[0]["plants"][0]["amount"] == "soak 10 min")
    case("amount scales with pot", lambda: int(run("A,pothos,25,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]) > int(run("A,pothos,12,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]))
    case("low humidity shortens", lambda: run("A,fern,20,plastic,medium,yes,\n", humidity="low")[0]["plants"][0]["interval_now"] <= run("A,fern,20,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"])
    case("interval never below 1 day", lambda: run("A,basil,6,fabric,direct,yes,\n", start=dt.date(2026, 7, 6), humidity="low")[0]["plants"][0]["interval_now"] >= 1)
    case("missing pot size assumed 15", lambda: (lambda r: r[0]["plants"][0]["pot_cm"] == 15 and any("assumed 15" in w[2] for w in r[1]))(run("A,pothos,,plastic,medium,yes,\n")))
    def bad(rows):
        try:
            load_plants(H + rows); return False
        except InputError:
            return True
    case("bad material rejected", lambda: bad("A,pothos,15,wood,medium,yes,\n"))
    case("bad light rejected", lambda: bad("A,pothos,15,plastic,dim,yes,\n"))
    case("pot in mm rejected", lambda: bad("A,pothos,150,plastic,medium,yes,\n"))
    case("bad date rejected", lambda: bad("A,pothos,15,plastic,medium,yes,30/10/2026\n"))
    case("ics has one event per date, CRLF, escaped commas", lambda: (lambda r, t: t.count("BEGIN:VEVENT") == len(r["calendar"]) and "\r\n" in t and "\\," in t)(*(lambda r: (r, to_ics(r, "20261030T000000Z")))(run("A,pothos,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0])))
    case("ics lines folded to 75 octets", lambda: all(len(l.encode()) <= 75 for l in to_ics(run("".join(f"Plant number {i},pothos,15,plastic,medium,yes,\n" for i in range(8)))[0], "20261030T000000Z").split("\r\n")))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Build a houseplant watering check calendar")
    ap.add_argument("plants", nargs="?")
    ap.add_argument("--start", help="first day of the plan, YYYY-MM-DD (default today)")
    ap.add_argument("--weeks", type=int, default=4)
    ap.add_argument("--hemisphere", choices=["north", "south"], default="north")
    ap.add_argument("--humidity", choices=list(HUMIDITY), default="normal", help="low = heating on / below ~35 %%")
    ap.add_argument("--check-days", help="snap checks to these weekdays, e.g. Mon,Thu")
    ap.add_argument("--ics", help="also write an .ics calendar file")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.plants:
        ap.error("give a plants CSV, or --self-test")
    try:
        start = parse_date(a.start, "--start") if a.start else dt.date.today()
        if not 1 <= a.weeks <= 26:
            raise InputError("--weeks must be 1..26")
        days = None
        if a.check_days:
            days = [x.strip()[:3].title() for x in a.check_days.split(",") if x.strip()]
            for x in days:
                if x not in DAY_NAMES:
                    raise InputError(f"unknown weekday '{x}' in --check-days")
        with open(a.plants, encoding="utf-8") as fh:
            plants, warnings = load_plants(fh.read())
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    res = plan(plants, start, a.weeks, a.hemisphere, a.humidity, days)
    if a.ics:
        stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        with open(a.ics, "w", encoding="utf-8", newline="") as fh:
            fh.write(to_ics(res, stamp))
    if a.json:
        out = {**res, "start": res["start"].isoformat(), "end": res["end"].isoformat(),
               "calendar": [{"date": d.isoformat(), "plants": n} for d, n in res["calendar"]],
               "late_snaps": [{"plant": n, "due": d.isoformat(), "planned": p.isoformat()} for n, d, p in res["late_snaps"]],
               "plants": [{**p, "last_watered": p["last_watered"].isoformat() if p["last_watered"] else None} for p in res["plants"]],
               "warnings": [{"severity": s, "plant": n, "note": m} for s, n, m in warnings]}
        print(json.dumps(out, indent=2))
    else:
        sys.stdout.write(render(res, warnings))
    return 1 if any(w[0] == "HIGH" for w in warnings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonGardeningPlanning+1
F@f
0
Công Cụ Tính Toán & So Sánh Chi Phí Đi Lại
Kỹ năng

So sánh các phương án đi làm - ô tô (xăng hoặc xe điện), phương tiện công cộng, xe đạp, xe đạp điện, đi bộ, kết hợp luân phiên theo ngày và chặng gửi xe đi tàu/buýt (park-and-ride) - theo tháng và năm kèm chi phí tiền mặt, thời gian di chuyển trọn gói từ cửa đến cửa, chi phí thời gian tính theo mức giá trị giờ của riêng bạn, chi phí mỗi chuyến và lượng phát thải CO2. Tự động chọn giữa vé tháng và trả tiền từng lượt dựa trên số ngày bạn đi, tách biệt chi phí vận hành xe với chi phí cố định, đồng thời hiển thị mức giá trị hòa vốn của một giờ. Bao gồm bộ tính toán bằng Python sử dụng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.

---
name: commute-cost-comparison-calculator
description: So sánh các phương án đi làm - ô tô (xăng hoặc xe điện), phương tiện công cộng, xe đạp, xe đạp điện, đi bộ, kết hợp luân phiên theo ngày và chặng gửi xe đi tàu/buýt (park-and-ride) - theo tháng và năm kèm chi phí tiền mặt, thời gian di chuyển trọn gói từ cửa đến cửa, chi phí thời gian tính theo mức giá trị giờ của riêng bạn, chi phí mỗi chuyến và lượng phát thải CO2. Tự động chọn giữa vé tháng và trả tiền từng lượt dựa trên số ngày bạn đi, tách biệt chi phí vận hành xe với chi phí cố định, đồng thời hiển thị mức giá trị hòa vốn của một giờ. Bao gồm bộ tính toán bằng Python sử dụng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---

# Công Cụ Tính Toán & So Sánh Chi Phí Đi Lại

Bạn giúp người dùng quyết định cách đi làm bằng việc đặt mọi phương án lên cùng một bàn cân: tốn bao nhiêu tiền mặt, lấy đi bao nhiêu thời gian trong cuộc sống của họ, và khoảng thời gian đó đáng giá bao nhiêu đối với họ. Bạn chỉ tính những khoản thực sự thay đổi theo lựa chọn (nhiên liệu, phí gửi xe, giá vé, chi phí xe đạp), loại bỏ các khoản họ đằng nào cũng phải trả trừ khi họ thực sự bán chiếc ô tô đó, và thể hiện sự đánh đổi bằng một con số rõ ràng: "lái xe tiết kiệm được 32 giờ mất đi mỗi năm và tốn thêm $3,697, vì vậy phương án này chỉ đáng giá nếu một giờ của bạn trị giá hơn $115".

## Các tệp trong kỹ năng này

- `scripts/commute_cost.py` - đọc tệp JSON kịch bản (khoảng cách, số ngày đi làm, giá trị thời gian, các phương án) và in ra bảng so sánh, tóm tắt nổi bật, giá trị thời gian hòa vốn và ghi chú cho từng phương án; hỗ trợ hệ mét (km, L/100 km) và hệ Anh-Mỹ (mi, mpg), xe điện (EV), vé tháng phương tiện công cộng có mức trần chi phí ngày và trợ cấp từ công ty, phương án kết hợp luân phiên theo ngày và chuyến đi nhiều chặng (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/cost-components.md` - mọi trường thông tin của phương án, những khoản cần đưa vào cho từng loại phương tiện, nơi tra cứu số liệu và các khoảng giá trị điển hình
- `references/time-value-and-assumptions.md` - cách chọn giá trị cho một giờ thời gian, thời gian có thể tận dụng trên phương tiện công cộng, những yếu tố chưa nằm trong tổng chi phí (sức khỏe, độ an toàn, thời tiết, sự linh hoạt) và cách chạy kiểm tra độ nhạy
- `templates/commute-comparison.md` - bản tóm tắt quyết định để gửi lại cho người dùng
- `examples/commute.json` - quãng đường đi làm 11 dặm với các phương án lái xe, xe buýt + tàu điện nhẹ (light rail), xe đạp điện, kết hợp xe đạp điện/phương tiện công cộng và gửi xe đi tàu/buýt (park-and-ride)
- `examples/comparison-output.md` - kết quả đầu ra thực tế của công cụ tính toán cho kịch bản đó

## Quy trình thực hiện

### 1. Thu thập thông tin thực tế
Khoảng cách một chiều, số ngày đi làm mỗi tuần (làm việc kết hợp hybrid ảnh hưởng rất lớn) và số tuần làm việc mỗi năm, số phút di chuyển từ cửa đến cửa cho mỗi phương án (bao gồm cả đi bộ, chờ đợi, gửi xe và chuyển tuyến: hãy hỏi thời gian chuyến đi thực tế chứ không chỉ lấy ước tính trên bản đồ), và thông tin theo từng phương tiện: mức tiêu thụ nhiên liệu của ô tô, giá xăng/dầu hoặc giá điện, phí gửi xe và cầu đường, liệu họ có giữ lại chiếc xe dù không lái đi làm hay không; giá vé lẻ phương tiện công cộng, giá vé tháng, mức trần chi phí ngày, trợ cấp của công ty; giá mua xe đạp hoặc xe đạp điện, tuổi thọ dự kiến và chi phí bảo dưỡng. Hãy hỏi xem một giờ thời gian của họ đáng giá bao nhiêu, hoặc đưa ra một mức mặc định (bằng một nửa mức lương theo giờ của họ) và trình bày phân tích độ nhạy.

### 2. Chạy công cụ tính toán
```
python3 scripts/commute_cost.py examples/commute.json
python3 scripts/commute_cost.py scenario.json --days-per-week 3
python3 scripts/commute_cost.py scenario.json --value-of-time 0 --json
python3 scripts/commute_cost.py --self-test
```
Mã thoát 0 = đã in bảng so sánh, 2 = dữ liệu đầu vào không hợp lệ (thiếu trường, tỷ lệ kết hợp cộng lại không bằng 1, loại phương tiện không xác định, giá âm). Nếu không chạy mã, hãy làm theo các công thức trong `references/cost-components.md`.

### 3. Đọc kết quả
- Bảng được sắp xếp theo tổng chi phí (tiền mặt + chi phí thời gian); dòng tiêu đề chỉ ra phương án rẻ nhất về tiền mặt, rẻ nhất khi tính cả thời gian và nhanh nhất.
- **Giá trị thời gian hòa vốn (Break-even value of time)** so sánh từng phương án với phương án rẻ nhất về tiền mặt: một phương án tốn nhiều tiền hơn chỉ đáng chọn nếu một giờ của bạn có giá trị cao hơn con số được hiển thị. "Never" (Không bao giờ) có nghĩa là phương án đó vừa tốn kém hơn vừa không tiết kiệm được thời gian hữu ích.
- Thời gian ngồi trên phương tiện công cộng mà bạn có thể tận dụng (đọc sách, xử lý email) được khấu trừ thông qua `usable_time_share`; cùng trường này cũng có thể dùng để cộng điểm cho thời gian đạp xe nếu việc đó thay thế cho thời gian tập thể dục mà đằng nào bạn cũng thực hiện. Hãy nêu rõ bạn đã áp dụng cách nào.
- Kiểm tra ghi chú về phương tiện công cộng: khi chỉ đi làm 2-3 ngày một tuần, vé tháng thường đắt hơn trả tiền từng lượt; công cụ tính toán sẽ tự chọn cách rẻ hơn và ghi chú rõ điều đó.

### 4. Bàn giao kết quả
Điền vào `templates/commute-comparison.md`: khuyến nghị gói gọn trong một câu, bảng so sánh, các mức giá trị hòa vốn, những yếu tố làm thay đổi câu trả lời (số ngày làm hybrid, giá nhiên liệu, phí gửi xe, giá trị thời gian), những yếu tố chưa được định giá bằng tiền, và gợi ý dùng thử (ví dụ: thử nghiệm phương án kết hợp trong hai tuần trước khi mua sắm bất cứ thứ gì).

## Ví dụ kết quả
11 dặm mỗi chiều, 5 ngày một tuần, 46 tuần, thời gian được định giá ở mức $20/giờ (`examples/commute.json`). Rẻ nhất về tiền mặt: xe buýt + tàu điện nhẹ ở mức $49/tháng sau khi trừ $50 trợ cấp từ công ty (vé tháng $99 rẻ hơn so với $105 tiền mua vé lẻ). Rẻ nhất khi tính cả thời gian: gửi xe đi tàu/buýt - park and ride ($95 tiền mặt, tổng cộng $361/tháng) vì 80 % thời gian chặng đi tàu có thể tận dụng làm việc khác. Lái xe vào trung tâm thành phố là nhanh nhất (28 phút) nhưng tốn $357/tháng với $12/ngày phí gửi xe và chỉ đáng giá nếu một giờ trị giá hơn $115. Xe đạp điện tốn trọn gói $67/tháng và không phát thải, nhưng không tiết kiệm thời gian hữu ích với các thông số này; phương án này sẽ chiến thắng nếu việc đạp xe thay thế cho thời gian tập gym. Xem `examples/comparison-output.md`.

## Quy tắc

- Chỉ tính những chi phí thực sự thay đổi theo lựa chọn; chỉ đưa chi phí bảo hiểm ô tô, đăng kiểm và lãi vay mua xe vào nếu người đó thực sự từ bỏ chiếc xe (`count_fixed`).
- Sử dụng thời gian thực tế từ cửa đến cửa, bao gồm cả đi bộ, chờ đợi, gửi xe và chuyển tuyến.
- Không bao giờ trình bày giá trị thời gian như một sự thật hiển nhiên: đó là lựa chọn riêng của mỗi người; hãy hiển thị ít nhất một lần chạy kiểm tra độ nhạy (ví dụ: `--value-of-time 0` và mức lương theo giờ của họ).
- Giữ các yếu tố sức khỏe, an toàn và thời tiết nằm ngoài tổng số tiền nhưng hãy đề cập đến chúng; không rao giảng đạo đức về việc đi ô tô hay xe đạp.
- Nêu rõ mọi giả định kèm theo con số cụ thể (giá nhiên liệu, phí gửi xe, khấu hao trên mỗi dặm) để người dùng có thể tự điều chỉnh.
- Sử dụng đơn vị tiền tệ và đơn vị đo lường đúng như người dùng đang sử dụng; không trộn lẫn dặm (miles) và kilômét (kilometres) trong cùng một kịch bản.
FILE:references/cost-components.md
# Các thành phần chi phí và trường thông tin phương án

Các trường của kịch bản: `currency` (USD, EUR, GBP, TRY, ...), `units` (`metric`: km, L/100 km, giá mỗi lít; `imperial`: dặm/miles, mpg, giá mỗi gallon Mỹ), `one_way_distance`, `days_per_week` (mặc định là 5), `weeks_per_year` (mặc định là 46: trừ ngày lễ, ngày nghỉ ốm, công tác), `value_of_time_per_hour`, `options`.

Mọi phương án đều có: `name`, `mode`, `minutes_one_way` (từ cửa đến cửa), `distance_one_way` tùy chọn (mặc định lấy theo khoảng cách của kịch bản), `usable_time_share` tùy chọn (0..1).

## Ô tô (`mode: car`)
| Trường | Ý nghĩa | Giá trị điển hình |
|---|---|---|
| `fuel_economy_mpg` / `fuel_l_per_100km` | mức tiêu thụ thực tế trên tuyến đường này (lái xe trong phố tốn hơn thông số nhãn) | 25-35 mpg / 6-9 L/100 km |
| `fuel_price` | tính theo mỗi gallon / mỗi lít | giá tại trạm xăng địa phương |
| `fuel: "diesel"` | sử dụng hệ số phát thải CO2 của dầu diesel | mặc định là xăng (petrol) |
| `kwh_per_100` + `electricity_price` | xe điện thay vì nhiên liệu đốt trong (kWh trên mỗi 100 dặm hoặc km) | 25-35 kWh/100 mi, 16-22 kWh/100 km |
| `grid_kg_co2_per_kwh` | cường độ phát thải của lưới điện để tính CO2 cho xe điện | mặc định 0.4; 0.05 với thủy điện/điện hạt nhân, 0.7 với lưới điện phụ thuộc nhiều vào than đá |
| `maintenance_per_distance` | lốp xe, bảo dưỡng định kỳ, sửa chữa trên mỗi dặm/km | $0.08-0.12/mi, 0.05-0.08/km |
| `depreciation_per_distance` | phần giá trị mất đi chỉ do số dặm/km chạy thêm (không tính hao mòn theo tuổi xe) | $0.05-0.15/mi |
| `parking_per_day`, `tolls_per_day` | tính trên mỗi ngày đi làm | phí gửi xe tại nơi làm việc có thể chiếm tỷ trọng lớn nhất |
| `fixed_per_year` + `count_fixed` | bảo hiểm, đăng kiểm, lãi vay, khấu hao theo tuổi xe; chỉ được tính khi `count_fixed` là true | chỉ đặt là true nếu việc đi làm là lý do duy nhất để sở hữu chiếc xe |

Chi phí nhiên liệu = 2 x khoảng cách x số ngày / mpg x giá (hoặc x L/100 km / 100 x giá). Khí thải CO2: xăng 2.35 kg/L (8.89 kg mỗi gallon Mỹ), dầu diesel 2.68 kg/L (10.18 kg/gal), xe điện tính bằng số kWh x hệ số phát thải lưới điện.

Các mức chi phí trọn gói trên mỗi dặm (định mức khấu trừ thuế theo dặm, báo cáo "Your Driving Costs" của AAA) đã bao gồm cả chi phí cố định; chúng làm phóng đại chi phí cận biên của việc đi làm khi đằng nào người dùng vẫn giữ lại chiếc xe.

## Phương tiện công cộng (`mode: transit`)
| Trường | Ý nghĩa |
|---|---|
| `fare_per_ride`, `rides_per_day` (mặc định là 2) | giá vé lẻ; cộng thêm một lượt cho mỗi lần chuyển tuyến có thu phí (không được miễn phí) |
| `daily_cap` | mức trần giá vé tối đa trong ngày, nếu hệ thống giao thông có áp dụng |
| `monthly_pass` | giá vé tháng; công cụ tính toán sẽ so sánh 12 vé tháng với chi phí trả từng lượt cho số ngày thực tế đi lại và chọn phương án rẻ hơn |
| `subsidy_per_month` | khoản trợ cấp từ công ty hoặc phúc lợi miễn thuế, được khấu trừ trực tiếp (không bao giờ giảm xuống dưới 0) |
| `co2_g_per_passenger_km` | mặc định 80 g; tàu điện 20-40, xe buýt chạy dầu diesel 80-120 ở mức tải trọng trung bình |
| `usable_time_share` | tỷ lệ thời gian chuyến đi bạn có thể đọc sách hoặc làm việc (có chỗ ngồi, không bị căng thẳng vì chuyển tuyến): 0.2-0.8 |

## Xe đạp, xe đạp điện, đi bộ (`mode: bike | ebike | walk`)
| Trường | Ý nghĩa | Giá trị điển hình |
|---|---|---|
| `purchase_price`, `life_years` | phân bổ đều trong suốt vòng đời sử dụng (mặc định 5 năm); không dùng cho đi bộ | xe đạp điện $1,500-3,500 |
| `maintenance_per_year` | lốp, xích, má phanh, quỹ thay pin cho xe đạp điện | $100-300 |
| `gear_per_year` | đèn, khóa, áo mưa, thay mũ bảo hiểm | $50-150 |
| `kwh_per_100`, `electricity_price` | chi phí sạc xe đạp điện | 1-2 kWh trên mỗi 100 dặm |
| `other_per_month` | phí gửi xe đạp, thuê tủ đồ, thẻ phòng tắm | 0-30 |
Chi phí sở hữu được tính toàn bộ ngay cả trong phương án kết hợp: bạn vẫn phải trả tiền mua chiếc xe đạp dù đi 3 hay 5 ngày một tuần.

## Kết hợp (`mode: mix`)
`"shares": {"E-bike": 0.6, "Bus + light rail": 0.4}` chia số ngày đi làm giữa các phương án khác nhau (tổng các tỷ lệ cộng lại bằng 1). Mỗi phần được tính chi phí theo đúng số ngày sử dụng của phần đó, vì vậy phần đi phương tiện công cộng trong 2 ngày một tuần thường sẽ chuyển sang hình thức trả tiền từng lượt. Hãy sử dụng chế độ này cho việc đạp xe phụ thuộc vào thời tiết, lái xe kết hợp (hybrid), hoặc những ngày đi chung xe (carpool).

## Nhiều chặng (`mode: multi`)
`"legs": [ {car leg}, {transit leg} ]` cùng với `transfer_minutes`. Mỗi chặng cần có `distance_one_way` và `minutes_one_way` riêng; chi phí và số phút sẽ được cộng dồn lại. Hãy dùng chế độ này cho hình thức gửi xe đi tàu/buýt (park and ride), xe đạp + tàu hỏa, hoặc đi bộ + xe buýt.

## Lấy số liệu ở đâu
- Mức tiêu thụ nhiên liệu: xem trên đồng hồ hành trình (trip computer) của ô tô trong suốt một tuần đi làm thực tế.
- Phí gửi xe và giá vé: trang bảng giá của đơn vị vận hành; kiểm tra các phúc lợi từ công ty (trợ cấp phương tiện công cộng trước thuế, quy đổi chỗ đỗ xe thành tiền mặt - parking cash-out).
- Thời gian từ cửa đến cửa: bấm giờ một chuyến đi thực tế cho mỗi chiều vào đúng khung giờ cao điểm đi làm; cộng thêm 5 phút dự phòng cho sự dao động khi tìm chỗ đỗ xe hoặc chờ tàu/buýt.
FILE:references/time-value-and-assumptions.md
# Giá trị thời gian và những yếu tố chưa nằm trong tổng chi phí

## Cách chọn giá trị thời gian
- Không có con số nào là đúng tuyệt đối: đó là mức giá trị mà một giờ đi làm thực sự mang lại cho chính người đó.
- Mốc tham chiếu phổ biến: khoảng một nửa mức lương thực nhận theo giờ đối với việc di chuyển cá nhân (hướng dẫn định giá thời gian di chuyển của Bộ Giao thông Vận tải Hoa Kỳ sử dụng mức 50 % thu nhập theo giờ cho các chuyến đi cá nhân tại địa phương; di chuyển công tác được định giá ở mức 100 %).
- Một người hưởng lương cố định, không thể kiếm thêm thu nhập nhờ giảm thời gian đi lại có thể chọn mức giá trị thấp hơn; một người làm nghề tự do (freelancer) có thể tính phí cho những giờ tiết kiệm được sẽ chọn mức cao hơn.
- Luôn trình bày phân tích độ nhạy: `--value-of-time 0` (chỉ tính tiền mặt), mức giá trị đã chọn, và toàn bộ mức lương theo giờ của họ. Nếu thứ hạng các phương án không thay đổi, quyết định đó là vững chắc.

## Thời gian có thể tận dụng
`usable_time_share` khấu trừ khoảng thời gian bạn có thể tận dụng làm việc khác. Hướng dẫn ước tính sơ bộ:
| Tình huống | Tỷ lệ |
|---|---|
| Lái xe, đạp xe giữa dòng giao thông, đứng trên xe buýt đông đúc | 0 |
| Đi xe buýt có chỗ ngồi, các chặng ngắn, phải chuyển tuyến | 0.2-0.4 |
| Đi tàu có chỗ ngồi và bàn làm việc, hơn 20 phút liên tục không phải đổi chuyến | 0.6-0.8 |
| Đạp xe hoặc đi bộ giúp thay thế cho thời gian tập thể dục mà đằng nào bạn cũng làm | 0.3-0.7 (hãy nêu rõ điều này) |

## Những yếu tố không nằm trong tổng số tiền (cần đề cập đến)
- **Sức khỏe:** đạp xe hoặc đi bộ thường xuyên gắn liền với sức khỏe tim mạch tốt hơn; một số người đánh giá rất cao lợi ích này, số khác lại không quan tâm. Hãy sử dụng `usable_time_share` nếu họ muốn quy đổi lợi ích này thành tiền.
- **Độ an toàn:** rủi ro tai nạn giao thông trên mỗi dặm khác nhau tùy theo phương tiện và tuyến đường; một làn đường dành riêng cho xe đạp có rào chắn bảo vệ sẽ làm thay đổi hoàn toàn bức tranh tổng thể.
- **Thời tiết và mùa vụ:** hãy sử dụng phương án kết hợp (ví dụ: 60 % số ngày đi xe đạp) thay vì giả định ngày nào cũng có thể đạp xe.
- **Độ ổn định (Reliability):** một chuyến tàu mất 40 phút với biên độ dao động 10 phút có thể tốt hơn một chuyến lái xe trung bình 30 phút nhưng dao động từ 25 đến 60 phút; hãy hỏi về các khoản phạt hoặc hậu quả khi trễ giờ (ví dụ: đón con muộn tại nhà trẻ).
- **Sự linh hoạt và giá trị quyền chọn:** việc có sẵn ô tô để tạt qua giải quyết việc vặt trên đường, hoặc cho các tình huống khẩn cấp; dịch vụ thuê xe theo giờ hoặc ngân sách đi taxi có thể thay thế cho những ngày hiếm hoi đó (cộng thêm vào mục `other_per_month`).
- **Mức độ căng thẳng và sự thoải mái:** đừng định giá bằng tiền, nhưng hãy hỏi người dùng về yếu tố này.

## Các kịch bản kiểm tra độ nhạy đáng thực hiện
- Làm việc kết hợp (Hybrid work): `--days-per-week 3` thường làm đảo ngược sự lựa chọn giữa vé tháng và trả tiền từng lượt, đồng thời khiến việc sở hữu chiếc ô tô thứ hai trở nên kém kinh tế hơn hẳn.
- Phí gửi xe: sự thay đổi về giá gửi xe (hoặc khoản tiền công ty thưởng khi không dùng chỗ đỗ xe) thường là đòn bẩy đơn lẻ lớn nhất.
- Giá nhiên liệu hoặc giá điện biến động +-25 %.
- Bán chiếc ô tô thứ hai: chạy lại tính toán với `count_fixed: true` cho phương án ô tô để thấy toàn bộ khoản tiền tiết kiệm được.

## Kiểm tra tính hợp lý trước khi trình bày
- Chi phí tiền mặt mỗi chuyến khi lái xe tối thiểu phải bằng chi phí nhiên liệu + phí gửi xe mỗi chuyến.
- Chi phí tiền mặt mỗi tháng cho phương tiện công cộng không được vượt quá giá vé tháng trừ khi vé tháng không bao phủ hết tuyến đường đó.
- Số giờ mỗi tháng = số phút một chiều x 2 x số ngày mỗi tháng / 60.
- Nếu phương án "chiến thắng" phụ thuộc vào một mức giá trị thời gian cách quá xa so với con số người dùng đưa ra, hãy nói rõ điều đó một cách thẳng thắn.
FILE:templates/commute-comparison.md
# Các phương án đi làm: <nhà> đến <nơi làm việc>

**Chuyến đi:** <11 dặm mỗi chiều, 5 ngày/tuần, 46 tuần/năm>   **Giá trị thời gian áp dụng:** <$20/giờ (khoảng một nửa lương theo giờ)>
**Khuyến nghị:** <một câu, ví dụ: "Gửi xe đi tàu/buýt (Park and ride): $95/tháng tiền mặt, 40 phút từ cửa đến cửa, phần lớn thời gian đi tàu có thể tận dụng để xử lý email">

## Bảng so sánh (theo tháng)
| Phương án | Tiền mặt | Từ cửa đến cửa | Số giờ | Chi phí thời gian | Tổng cộng | CO2 kg/năm |
|---|---|---|---|---|---|---|
| <Park and ride> | <$95> | <40 min> | <25.6> | <$266> | <$361> | <999> |
| <Bus + light rail> | <$49> | <46 min> | <29.4> | <$411> | <$460> | <651> |

## Khi nào một phương án đắt đỏ hơn mới đáng tiền
| Phương án | Chi phí tăng thêm mỗi năm | Số giờ mất đi được tiết kiệm | Đáng giá nếu một giờ trị giá trên |
|---|---|---|---|
| <Drive downtown> | <$3,697> | <32> | <$115/hour> |

## Những yếu tố sẽ làm thay đổi câu trả lời
- <Làm việc kết hợp (3 ngày): vé tháng phương tiện công cộng không còn có lợi; trả tiền từng lượt ...>
- <Khoản tiền thưởng không dùng chỗ đỗ xe từ công ty sẽ khiến việc lái xe đắt thêm $230/tháng nữa>
- <Nếu chuyến đi bằng xe đạp điện thay thế cho việc tập gym, phương án xe đạp điện sẽ vươn lên ...>

## Những yếu tố chưa được định giá bằng tiền
- <sức khỏe, độ an toàn trên tuyến đường, những ngày thời tiết xấu, độ ổn định giờ giấc, nhu cầu dùng ô tô để đón con đi học>

## Các giả định
- <nhiên liệu $3.40/gal ở mức tiêu thụ 28 mpg; bảo dưỡng $0.10/mi; khấu hao theo số dặm $0.08/mi; phí gửi xe $12/ngày>
- <vé tháng phương tiện công cộng $99, trợ cấp từ công ty $50/tháng, 30 % thời gian đi xe buýt/tàu có thể tận dụng>
- <vẫn giữ ô tô để đi cuối tuần, do đó chi phí bảo hiểm và đăng kiểm không được tính vào>

## Bước tiếp theo
<Thử nghiệm phương án kết hợp trong hai tuần trước khi mua xe đạp điện; bấm giờ thực tế từng chuyến từ cửa đến cửa.>
FILE:examples/commute.json
{
  "currency": "USD",
  "units": "imperial",
  "one_way_distance": 11,
  "days_per_week": 5,
  "weeks_per_year": 46,
  "value_of_time_per_hour": 20,
  "options": [
    {
      "name": "Drive downtown",
      "mode": "car",
      "minutes_one_way": 28,
      "fuel_economy_mpg": 28,
      "fuel_price": 3.4,
      "maintenance_per_distance": 0.1,
      "depreciation_per_distance": 0.08,
      "parking_per_day": 12,
      "tolls_per_day": 0,
      "fixed_per_year": 1900,
      "count_fixed": false
    },
    {
      "name": "Bus + light rail",
      "mode": "transit",
      "minutes_one_way": 46,
      "fare_per_ride": 2.75,
      "monthly_pass": 99,
      "subsidy_per_month": 50,
      "usable_time_share": 0.3
    },
    {
      "name": "E-bike",
      "mode": "ebike",
      "minutes_one_way": 42,
      "purchase_price": 2200,
      "life_years": 5,
      "maintenance_per_year": 250,
      "gear_per_year": 100,
      "kwh_per_100": 1.5,
      "electricity_price": 0.17
    },
    {
      "name": "E-bike 3 days, transit 2 days",
      "mode": "mix",
      "shares": {"E-bike": 0.6, "Bus + light rail": 0.4}
    },
    {
      "name": "Park and ride",
      "mode": "multi",
      "transfer_minutes": 6,
      "legs": [
        {"mode": "car", "distance_one_way": 4, "minutes_one_way": 10, "fuel_economy_mpg": 28, "fuel_price": 3.4,
         "maintenance_per_distance": 0.1, "depreciation_per_distance": 0.08, "parking_per_day": 0},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 24, "fare_per_ride": 2.75, "monthly_pass": 99,
         "subsidy_per_month": 50, "usable_time_share": 0.8}
      ]
    }
  ]
}
FILE:examples/comparison-output.md
# So sánh chi phí đi làm

Một chiều: 11 dặm   Số ngày đi làm: 19.2/tháng (230/năm)   Giá trị thời gian: $20.00/giờ   Tiền tệ: USD

| Phương án | Tiền mặt / tháng | Tiền mặt / năm | Mỗi chuyến | Từ cửa đến cửa (một chiều) | Số giờ / tháng | Chi phí thời gian / tháng | Tổng cộng / tháng | CO2 kg / năm |
|---|---|---|---|---|---|---|---|---|
| Park and ride | $95.22 | $1,143 | $2.48 | 40 min | 25.6 | $266 | **$361** | 999 |
| Bus + light rail | $49.00 | $588 | $1.28 | 46 min | 29.4 | $411 | **$460** | 651 |
| E-bike 3 days, transit 2 days | $66.48 | $798 | $1.73 | 44 min | 27.9 | $487 | **$553** | 261 |
| E-bike | $66.91 | $803 | $1.75 | 42 min | 26.8 | $537 | **$604** | 0 |
| Drive downtown | $357 | $4,285 | $9.32 | 28 min | 17.9 | $358 | **$715** | 1,607 |

Được sắp xếp theo tổng chi phí (tiền mặt + chi phí thời gian).

## Điểm nổi bật

- Rẻ nhất về tiền mặt: **Bus + light rail** ($49.00/tháng).
- Rẻ nhất khi tính cả thời gian: **Park and ride** ($361/tháng ở mức $20.00/giờ).
- Nhanh nhất: **Drive downtown** (28 phút một chiều, 17.9 giờ/tháng).

## Giá trị thời gian hòa vốn (so với Bus + light rail, phương án rẻ nhất về tiền mặt)

| Phương án | Tiền mặt tăng thêm / năm | Số giờ mất đi tiết kiệm được / năm | Đáng giá nếu một giờ của bạn trị giá hơn |
|---|---|---|---|
| Drive downtown | $3,697 | 32 | $115/hour |
| E-bike | $215 | không có (mất thêm 75 giờ) | không bao giờ xét về chi phí: phương án này không tiết kiệm thời gian hữu ích |
| E-bike 3 days, transit 2 days | $210 | không có (mất thêm 45 giờ) | không bao giờ xét về chi phí: phương án này không tiết kiệm thời gian hữu ích |
| Park and ride | $555 | 87 | $6.35/hour |

## Ghi chú cho từng phương án

- **Drive downtown** (car): mỗi tháng: nhiên liệu/năng lượng $51.20, bảo dưỡng + khấu hao $75.90, phí gửi xe + cầu đường $230; chi phí cố định (bảo hiểm, đăng kiểm) không được tính: đằng nào bạn vẫn giữ lại chiếc xe
- **Bus + light rail** (transit): mỗi tháng: tiền vé $49.00; dùng vé tháng (rẻ hơn mức $105/tháng nếu trả từng lượt cho 19.2 ngày/tháng); đã khấu trừ trợ cấp từ công ty $50.00/tháng
- **E-bike** (ebike): mỗi tháng: tiền xe + bảo dưỡng $65.83, nhiên liệu/năng lượng $1.08; chi phí sở hữu được tính toàn bộ (chúng không giảm đi vào những ngày bạn không đạp xe)
- **E-bike 3 days, transit 2 days** (mix): E-bike 60 % số ngày (11.5/tháng): chi phí sở hữu được tính toàn bộ (chúng không giảm đi vào những ngày bạn không đạp xe); Bus + light rail 40 % số ngày (7.7/tháng): trả tiền từng lượt (mua vé tháng sẽ tốn tới $99.00/tháng cho 7.7 ngày/tháng)
- **Park and ride** (multi): chặng 2 (transit): dùng vé tháng (rẻ hơn mức $105/tháng nếu trả từng lượt cho 19.2 ngày/tháng); chặng 2 (transit): đã khấu trừ trợ cấp từ công ty $50.00/tháng

Chi phí thời gian = số giờ x giá trị thời gian x (1 - tỷ lệ chuyến đi bạn có thể tận dụng để đọc sách hoặc làm việc). Chưa bao gồm: lợi ích sức khỏe, rủi ro tai nạn, những ngày thời tiết xấu và giá trị quyền chọn của việc có sẵn ô tô; xem references/time-value-and-assumptions.md.
FILE:scripts/commute_cost.py
#!/usr/bin/env python3
"""Commute cost comparison calculator (Python 3.8+ standard library only).

Compares commute options - car (fuel or electric), transit, bike, e-bike, walking,
day-share mixes ("bike 3 days, train 2 days") and same-day multi-leg trips ("drive to
the station, then train") - per month and per year: cash cost, time spent, time cost at
your own hourly value, total, cost per trip and CO2. For transit it picks the cheaper of
pay-per-ride (with a daily cap) and a monthly pass for the days you actually ride; for
cars it separates running costs from fixed costs you would keep paying anyway. It also
prints break-even values of time: how much an hour must be worth to you before a faster,
dearer option pays off.

Usage:
  python3 commute_cost.py scenario.json [--value-of-time 25] [--days-per-week 3] [--json]
  python3 commute_cost.py --self-test
scenario.json: {"currency": "USD", "units": "imperial"|"metric", "one_way_distance": 11,
  "days_per_week": 5, "weeks_per_year": 46, "value_of_time_per_hour": 20,
  "options": [{"name": "...", "mode": "car|transit|bike|ebike|walk|mix|multi", ...}]}
See references/cost-components.md for every option field.
Exit codes: 0 = comparison printed, 2 = bad input.
"""
import argparse, json, sys

PETROL_CO2 = {"metric": 2.35, "imperial": 8.89}   # kg CO2 per litre / per US gallon burned (diesel: 2.68 / 10.18)
DIESEL_CO2 = {"metric": 2.68, "imperial": 10.18}
KM_PER_UNIT = {"metric": 1.0, "imperial": 1.609344}
LABELS = {"energy": "fuel/energy", "running": "maintenance + depreciation", "parking_tolls": "parking + tolls",
          "fixed": "fixed costs", "fares": "fares", "ownership": "bike + upkeep", "other": "other"}
MODES = {"car", "transit", "bike", "ebike", "walk", "mix", "multi"}
SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "TRY": "₺", "CAD": "C$", "AUD": "A$", "JPY": "¥", "INR": "₹"}


class InputError(Exception):
    pass


def num(o, key, default=None, lo=0.0, hi=None, who=""):
    v = o.get(key, default)
    if v is None:
        raise InputError(f"{who}: '{key}' is required")
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise InputError(f"{who}: '{key}' must be a number")
    if v < lo or (hi is not None and v > hi):
        raise InputError(f"{who}: '{key}' = {v} is out of range" + (f" ({lo}..{hi})" if hi is not None else f" (>= {lo})"))
    return float(v)


def car_cost(o, sc, days, who):
    """Annual cost dict for a car over `days` commute days."""
    units = sc["units"]
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days      # user units per year
    if "kwh_per_100" in o:
        kwh = d / 100 * num(o, "kwh_per_100", who=who)
        energy = kwh * num(o, "electricity_price", who=who)
        co2 = kwh * num(o, "grid_kg_co2_per_kwh", 0.4, who=who)
    else:
        if units == "imperial":
            vol = d / num(o, "fuel_economy_mpg", lo=1, who=who)
        else:
            vol = d / 100 * num(o, "fuel_l_per_100km", lo=0.5, who=who)
        energy = vol * num(o, "fuel_price", who=who)
        co2 = vol * (DIESEL_CO2 if o.get("fuel") == "diesel" else PETROL_CO2)[units]
    running = d * (num(o, "maintenance_per_distance", 0, who=who) + num(o, "depreciation_per_distance", 0, who=who))
    daily = days * (num(o, "parking_per_day", 0, who=who) + num(o, "tolls_per_day", 0, who=who))
    fixed = num(o, "fixed_per_year", 0, who=who) if o.get("count_fixed", False) else 0.0
    return {"energy": energy, "running": running, "parking_tolls": daily, "fixed": fixed,
            "co2": co2, "notes": ([] if o.get("count_fixed", False) or not o.get("fixed_per_year") else
                                  ["fixed costs (insurance, registration) not counted: you keep the car anyway"])}


def transit_cost(o, sc, days, who):
    rides = num(o, "rides_per_day", 2, lo=1, who=who)
    fare = num(o, "fare_per_ride", who=who)
    cap = o.get("daily_cap")
    per_day = fare * rides if cap is None else min(fare * rides, num(o, "daily_cap", who=who))
    payg_year = per_day * days
    best, note = payg_year, "pay per ride"
    if o.get("monthly_pass") is not None:
        pass_year = num(o, "monthly_pass", who=who) * 12
        months_days = days / 12
        if pass_year < payg_year:
            best, note = pass_year, f"monthly pass (cheaper than {fmt_plain(payg_year / 12, sc['sym'])}/month pay per ride at {months_days:.1f} days/month)"
        else:
            note = f"pay per ride (pass would cost {fmt_plain(pass_year / 12, sc['sym'])}/month at {months_days:.1f} days/month)"
    subsidy = num(o, "subsidy_per_month", 0, who=who) * 12
    best = max(0.0, best - subsidy)
    km = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days * KM_PER_UNIT[sc["units"]]
    co2 = km * num(o, "co2_g_per_passenger_km", 80, who=who) / 1000
    return {"fares": best, "co2": co2, "notes": [note] + ([f"employer subsidy {fmt_plain(subsidy / 12, sc['sym'])}/month deducted"] if subsidy else [])}


def bike_cost(o, sc, days, who, mode):
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days
    own = 0.0
    if mode != "walk":
        life = num(o, "life_years", 5, lo=0.5, who=who)
        own = num(o, "purchase_price", 0, who=who) / life
    own += num(o, "maintenance_per_year", 0, who=who) + num(o, "gear_per_year", 0, who=who)
    energy = 0.0
    if mode == "ebike":
        kwh = d / 100 * num(o, "kwh_per_100", 1.2, who=who)
        energy = kwh * num(o, "electricity_price", 0, who=who)
    extra = num(o, "other_per_month", 0, who=who) * 12
    return {"ownership": own, "energy": energy, "other": extra, "co2": 0.0,
            "notes": ["ownership costs counted in full (they do not shrink on days you do not ride)"] if own else []}


def option_cost(o, sc, days, who):
    mode = o.get("mode")
    if mode == "car":
        c = car_cost(o, sc, days, who)
    elif mode == "transit":
        c = transit_cost(o, sc, days, who)
    elif mode in ("bike", "ebike", "walk"):
        c = bike_cost(o, sc, days, who, mode)
    else:
        raise InputError(f"{who}: mode '{mode}' is not allowed here (use car, transit, bike, ebike, walk)")
    minutes = num(o, "minutes_one_way", who=who, lo=1)
    cash = sum(v for k, v in c.items() if k not in ("co2", "notes"))
    return {"cash": cash, "minutes": minutes, "co2": c["co2"], "notes": c["notes"],
            "usable": num(o, "usable_time_share", 0, hi=1, who=who),
            "breakdown": {k: v for k, v in c.items() if k not in ("co2", "notes")}}


def evaluate(sc_raw, value_override=None, dpw_override=None):
    if not isinstance(sc_raw, dict) or not isinstance(sc_raw.get("options"), list) or not sc_raw["options"]:
        raise InputError("scenario needs an 'options' list")
    units = sc_raw.get("units", "metric")
    if units not in KM_PER_UNIT:
        raise InputError("units must be 'metric' or 'imperial'")
    cur = sc_raw.get("currency", "USD")
    sc = {"units": units, "distance": num(sc_raw, "one_way_distance", who="scenario", lo=0.1),
          "sym": SYMBOLS.get(cur, f"{cur} ")}
    dpw = dpw_override if dpw_override is not None else num(sc_raw, "days_per_week", 5, lo=0.5, hi=7, who="scenario")
    wpy = num(sc_raw, "weeks_per_year", 46, lo=1, hi=52, who="scenario")
    days = dpw * wpy
    vot = value_override if value_override is not None else num(sc_raw, "value_of_time_per_hour", 0, who="scenario")
    by_name = {}
    for i, o in enumerate(sc_raw["options"]):
        if not isinstance(o, dict) or not o.get("name") or o.get("mode") not in MODES:
            raise InputError(f"option {i + 1}: needs a 'name' and a 'mode' ({', '.join(sorted(MODES))})")
        if o["name"] in by_name:
            raise InputError(f"option name '{o['name']}' is used twice")
        by_name[o["name"]] = o
    results = []
    for o in sc_raw["options"]:
        who = o["name"]
        if o["mode"] == "mix":
            shares = o.get("shares")
            if not isinstance(shares, dict) or not shares:
                raise InputError(f"{who}: mix needs 'shares', e.g. {{\"Bike\": 0.6, \"Transit\": 0.4}}")
            tot = sum(shares.values())
            if abs(tot - 1) > 0.001:
                raise InputError(f"{who}: shares add up to {tot:g}, not 1")
            cash = co2 = hours = eff = 0.0
            notes, parts = [], []
            for nm, sh in shares.items():
                if nm not in by_name or by_name[nm]["mode"] in ("mix",):
                    raise InputError(f"{who}: share '{nm}' must name another non-mix option")
                sub = by_name[nm]
                part = multi_cost(sub, sc, days * sh, nm) if sub["mode"] == "multi" else option_cost(sub, sc, days * sh, nm)
                cash += part["cash"]; co2 += part["co2"]
                h = part["minutes"] * 2 * days * sh / 60
                hours += h; eff += h * (1 - part["usable"])
                parts.append(f"{nm} {sh * 100:.0f} % of days ({days * sh / 12:.1f}/month): {part['notes'][0] if part['notes'] else 'as above'}")
            minutes = hours * 60 / (2 * days)
            results.append(row(who, "mix", cash, minutes, hours, eff, vot, co2, days, parts))
            continue
        part = multi_cost(o, sc, days, who) if o["mode"] == "multi" else option_cost(o, sc, days, who)
        hours = part["minutes"] * 2 * days / 60
        results.append(row(who, o["mode"], part["cash"], part["minutes"], hours, hours * (1 - part["usable"]), vot,
                           part["co2"], days, part["notes"], part.get("breakdown")))
    return {"currency": sc_raw.get("currency", "USD"), "units": units, "one_way_distance": sc["distance"],
            "days_per_year": days, "days_per_month": days / 12, "value_of_time_per_hour": vot,
            "options": results, "break_even": break_even(results)}


def multi_cost(o, sc, days, who):
    legs = o.get("legs")
    if not isinstance(legs, list) or len(legs) < 2:
        raise InputError(f"{who}: multi needs at least two 'legs'")
    cash = co2 = minutes = 0.0
    notes, usable_min = [], 0.0
    for j, leg in enumerate(legs, 1):
        if leg.get("mode") in ("mix", "multi"):
            raise InputError(f"{who} leg {j}: legs cannot be mix or multi")
        if "distance_one_way" not in leg:
            raise InputError(f"{who} leg {j}: each leg needs its own 'distance_one_way'")
        p = option_cost(leg, sc, days, f"{who} leg {j}")
        cash += p["cash"]; co2 += p["co2"]; minutes += p["minutes"]
        usable_min += p["minutes"] * p["usable"]
        notes += [f"leg {j} ({leg['mode']}): {n}" for n in p["notes"]]
    minutes += num(o, "transfer_minutes", 0, who=who)
    return {"cash": cash, "co2": co2, "minutes": minutes, "notes": notes or ["legs summed"],
            "usable": usable_min / minutes if minutes else 0.0}


def row(name, mode, cash, minutes, hours, eff_hours, vot, co2, days, notes, breakdown=None):
    tcost = eff_hours * vot
    return {"name": name, "mode": mode, "cash_year": cash, "cash_month": cash / 12, "minutes_one_way": minutes,
            "hours_month": hours / 12, "lost_hours_month": eff_hours / 12, "time_cost_month": tcost / 12, "total_month": (cash + tcost) / 12,
            "total_year": cash + tcost, "cost_per_trip": cash / (2 * days), "co2_kg_year": co2,
            "notes": notes, "breakdown": breakdown or {}}


def break_even(results):
    """For each option, the hourly value of time at which it ties with the cheapest-cash option.
    Uses lost hours (time you cannot use for reading or work), the same basis as the time cost."""
    base = min(results, key=lambda r: r["cash_year"])
    out = []
    for r in results:
        if r is base:
            continue
        extra = r["cash_year"] - base["cash_year"]
        hours_saved = (base["lost_hours_month"] - r["lost_hours_month"]) * 12
        if hours_saved > 0.01:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": extra / hours_saved})
        else:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": None})
    return out


def fmt_plain(v, sym="$"):
    return f"{sym}{v:,.0f}" if abs(v) >= 100 else f"{sym}{v:,.2f}"


def render(res):
    sym = SYMBOLS.get(res["currency"], res["currency"] + " ")
    f = lambda v: fmt_plain(v, sym)
    du = "mi" if res["units"] == "imperial" else "km"
    out = [f"# Commute cost comparison", "",
           f"One way: {res['one_way_distance']:g} {du}   Commute days: {res['days_per_month']:.1f}/month ({res['days_per_year']:.0f}/year)   "
           f"Value of time: {f(res['value_of_time_per_hour'])}/hour   Currency: {res['currency']}", "",
           "| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |",
           "|---|---|---|---|---|---|---|---|---|"]
    rs = res["options"]
    for r in sorted(rs, key=lambda r: r["total_month"]):
        out.append(f"| {r['name']} | {f(r['cash_month'])} | {f(r['cash_year'])} | {f(r['cost_per_trip'])} | {r['minutes_one_way']:.0f} min | "
                   f"{r['hours_month']:.1f} | {f(r['time_cost_month'])} | **{f(r['total_month'])}** | {r['co2_kg_year']:,.0f} |")
    cheapest = min(rs, key=lambda r: r["cash_year"])
    best_total = min(rs, key=lambda r: r["total_year"])
    fastest = min(rs, key=lambda r: r["minutes_one_way"])
    out += ["", "Sorted by total (cash + time cost).", "",
            "## Headline", "",
            f"- Cheapest in cash: **{cheapest['name']}** ({f(cheapest['cash_month'])}/month).",
            f"- Cheapest once time counts: **{best_total['name']}** ({f(best_total['total_month'])}/month at {f(res['value_of_time_per_hour'])}/hour).",
            f"- Fastest: **{fastest['name']}** ({fastest['minutes_one_way']:.0f} min one way, {fastest['hours_month']:.1f} h/month)."]
    out += ["", f"## Break-even value of time (vs {cheapest['name']}, the cheapest in cash)", "",
            "| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |", "|---|---|---|---|"]
    for b in res["break_even"]:
        if b["break_even_per_hour"] is None:
            verdict = "never on cost: it does not save usable time" if b["extra_cash_year"] > 0 else "always: same cash, no more lost time"
            hs = f"{b['hours_saved_year']:.0f}" if b["hours_saved_year"] >= 0 else f"none ({-b['hours_saved_year']:.0f} more lost)"
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {hs} | {verdict} |")
        else:
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {b['hours_saved_year']:.0f} | {f(b['break_even_per_hour'])}/hour |")
    out += ["", "## Notes per option", ""]
    for r in rs:
        bd = ", ".join(f"{LABELS.get(k, k)} {f(v / 12)}" for k, v in r["breakdown"].items() if v) if r["breakdown"] else ""
        out.append(f"- **{r['name']}** ({r['mode']}): " + "; ".join(([f"per month: {bd}"] if bd else []) + r["notes"]))
    out += ["", "Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). "
            "Not included: health benefits, accident risk, weather days and the option value of having a car; "
            "see references/time-value-and-assumptions.md."]
    return "\n".join(out) + "\n"


def self_test():
    base = {"currency": "USD", "units": "metric", "one_way_distance": 10, "days_per_week": 5, "weeks_per_year": 48,
            "value_of_time_per_hour": 20}
    car = {"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_l_per_100km": 8, "fuel_price": 1.5,
           "maintenance_per_distance": 0.05, "parking_per_day": 5}
    tr = {"name": "Transit", "mode": "transit", "minutes_one_way": 40, "fare_per_ride": 2.5, "monthly_pass": 80}
    bike = {"name": "Bike", "mode": "bike", "minutes_one_way": 35, "purchase_price": 1000, "life_years": 5, "maintenance_per_year": 100}
    def ev(*opts, **kw):
        sc = dict(base, options=list(opts)); sc.update(kw.pop("sc", {}))
        return evaluate(sc, **kw)
    def get(res, n):
        return next(r for r in res["options"] if r["name"] == n)
    cases = []
    def case(name, fn):
        try:
            ok, err = bool(fn()), ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))
    # car: 240 days * 20 km = 4800 km; fuel 384 L * 1.5 = 576; maint 240; parking 1200 -> 2016/year
    case("car annual cash (fuel + maintenance + parking)", lambda: abs(get(ev(car), "Car")["cash_year"] - 2016) < 0.01)
    case("car CO2 petrol 2.35 kg/L", lambda: abs(get(ev(car), "Car")["co2_kg_year"] - 384 * 2.35) < 0.01)
    case("diesel CO2 factor", lambda: abs(get(ev(dict(car, fuel="diesel")), "Car")["co2_kg_year"] - 384 * 2.68) < 0.01)
    case("fixed costs excluded unless count_fixed", lambda: get(ev(dict(car, fixed_per_year=1500)), "Car")["cash_year"] == get(ev(car), "Car")["cash_year"])
    case("fixed costs included with count_fixed", lambda: abs(get(ev(dict(car, fixed_per_year=1500, count_fixed=True)), "Car")["cash_year"] - 3516) < 0.01)
    case("EV uses kWh and electricity price", lambda: abs(get(ev(dict(car, kwh_per_100=18, electricity_price=0.3, fuel_l_per_100km=None)), "Car")["breakdown"]["energy"] - 4800 / 100 * 18 * 0.3) < 0.01)
    case("imperial mpg and $/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["cash_year"] - 4800 / 30 * 3.6) < 0.01)
    case("imperial CO2 8.89 kg/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["co2_kg_year"] - 160 * 8.89) < 0.01)
    # transit: per ride 5/day*240 = 1200 vs pass 960 -> pass
    case("transit picks monthly pass when cheaper", lambda: abs(get(ev(tr), "Transit")["cash_year"] - 960) < 0.01)
    case("transit picks pay-per-ride at 2 days/week", lambda: abs(get(ev(tr, dpw_override=2), "Transit")["cash_year"] - 5 * 96) < 0.01)
    case("daily cap applied", lambda: abs(get(ev(dict(tr, monthly_pass=None, daily_cap=4)), "Transit")["cash_year"] - 4 * 240) < 0.01)
    case("employer subsidy deducted, never below 0", lambda: get(ev(dict(tr, subsidy_per_month=100)), "Transit")["cash_year"] == 0)
    case("bike ownership spread over life", lambda: abs(get(ev(bike), "Bike")["cash_year"] - 300) < 0.01)
    case("ebike electricity", lambda: abs(get(ev(dict(bike, name="E", mode="ebike", kwh_per_100=1.5, electricity_price=0.3)), "E")["breakdown"]["energy"] - 4800 / 100 * 1.5 * 0.3) < 0.01)
    case("walk has no purchase cost", lambda: get(ev({"name": "Walk", "mode": "walk", "minutes_one_way": 120, "purchase_price": 999}), "Walk")["cash_year"] == 0)
    case("time cost = hours x value", lambda: abs(get(ev(car), "Car")["time_cost_month"] - 20 * 2 * 240 / 60 * 20 / 12) < 0.01)
    case("usable time share discounts transit time", lambda: abs(get(ev(dict(tr, usable_time_share=0.5)), "Transit")["time_cost_month"] - 40 * 2 * 240 / 60 * 20 * 0.5 / 12) < 0.01)
    case("value-of-time override", lambda: get(ev(car, value_override=0), "Car")["time_cost_month"] == 0)
    case("per trip = annual cash / trips", lambda: abs(get(ev(car), "Car")["cost_per_trip"] - 2016 / 480) < 0.001)
    # mix: bike 60 % of days, transit 40 % -> transit 96 days: per ride 480 vs pass 960 -> per ride
    mixo = {"name": "Mix", "mode": "mix", "shares": {"Bike": 0.6, "Transit": 0.4}}
    case("mix: bike ownership full + transit pay-per-ride on its days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["cash_year"] - (300 + 480)) < 0.01)
    case("mix: time weighted by days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["minutes_one_way"] - (0.6 * 35 + 0.4 * 40)) < 0.01)
    multi = {"name": "P+R", "mode": "multi", "transfer_minutes": 5, "legs": [
        {"mode": "car", "distance_one_way": 3, "minutes_one_way": 8, "fuel_l_per_100km": 8, "fuel_price": 1.5, "parking_per_day": 2},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 20, "fare_per_ride": 2.5, "monthly_pass": 80}]}
    case("multi: legs summed + transfer", lambda: (lambda r: abs(r["minutes_one_way"] - 33) < 0.01 and abs(r["cash_year"] - (1440 * 0.08 * 1.5 + 480 + 960)) < 0.01)(get(ev(multi), "P+R")))
    case("break-even: car vs cheapest", lambda: (lambda res: (lambda b: abs(b["break_even_per_hour"] - (2016 - 300) / ((35 - 20) * 2 * 240 / 60)) < 0.01)(next(b for b in res["break_even"] if b["option"] == "Car")))(ev(car, bike)))
    case("break-even uses lost hours (usable transit time)", lambda: next(b for b in ev(dict(tr, usable_time_share=0.75, subsidy_per_month=60), bike)["break_even"] if b["option"] == "Bike")["break_even_per_hour"] is None)
    case("break-even: slower and dearer -> None", lambda: next(b for b in ev(car, tr, bike)["break_even"] if b["option"] == "Transit")["break_even_per_hour"] is None)
    case("render has headline and table", lambda: "Cheapest in cash: **Bike**" in render(ev(car, tr, bike)) and "| Car |" in render(ev(car, tr, bike)))
    case("render uses currency symbol", lambda: "€" in render(ev(car, sc={"currency": "EUR"})))
    def bad(*opts, **sc):
        try:
            ev(*opts, sc=sc); return False
        except InputError:
            return True
    case("mix shares must sum to 1", lambda: bad(bike, tr, dict(mixo, shares={"Bike": 0.6, "Transit": 0.6})))
    case("mix share must name an option", lambda: bad(bike, dict(mixo, shares={"Bike": 0.5, "Train": 0.5})))
    case("unknown mode rejected", lambda: bad({"name": "Boat", "mode": "boat", "minutes_one_way": 5}))
    case("duplicate names rejected", lambda: bad(car, car))
    case("missing fuel economy rejected", lambda: bad({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_price": 1.5}))
    case("negative price rejected", lambda: bad(dict(car, fuel_price=-1)))
    case("bad units rejected", lambda: bad(car, units="furlongs"))
    case("usable share above 1 rejected", lambda: bad(dict(tr, usable_time_share=1.5)))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Compare commute options per month and year, including time value")
    ap.add_argument("scenario", nargs="?")
    ap.add_argument("--value-of-time", type=float, help="override value_of_time_per_hour")
    ap.add_argument("--days-per-week", type=float, help="override days_per_week (e.g. 3 for hybrid work)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.scenario:
        ap.error("give a scenario JSON file, or --self-test")
    try:
        with open(a.scenario, encoding="utf-8") as fh:
            sc = json.load(fh)
        if a.days_per_week is not None and not 0.5 <= a.days_per_week <= 7:
            raise InputError("--days-per-week must be 0.5..7")
        res = evaluate(sc, a.value_of_time, a.days_per_week)
    except json.JSONDecodeError as e:
        print(f"error: scenario is not valid JSON: {e}", file=sys.stderr)
        return 2
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else render(res), end="" if not a.json else "\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinanceBudgetingDecision Making+2
F@f
0
Trước4 / 4