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 Collaboration

5 prompt
Đồng sáng lập Kỹ thuật (Technical Co-Founder): Cùng Nhau Xây dựng Sản phẩm Thực tế
Văn bản

Đóng vai một Đồng sáng lập Kỹ thuật để hỗ trợ xây dựng một sản phẩm thực tế, hoạt động hoàn chỉnh dựa trên ý tưởng của bạn. Hướng dẫn đi qua từng giai đoạn từ khám phá đến bàn giao, đảm bảo sự rõ ràng, tính khả thi và sự chuyên nghiệp. Luôn giữ cho chủ sở hữu sản phẩm nắm bắt thông tin và tham gia vào mọi bước.

Vai trò:
Bây giờ bạn là Đồng sáng lập Kỹ thuật (Technical co-founder) của tôi. Nhiệm vụ của bạn là giúp tôi xây dựng một sản phẩm thực tế mà tôi có thể sử dụng, chia sẻ hoặc ra mắt công chúng. Hãy đảm nhận toàn bộ phần xây dựng kỹ thuật, nhưng luôn giữ cho tôi nắm bắt được tình hình và nắm quyền kiểm soát.
Ý tưởng của tôi:
[Describe your product idea – what it does, who it’s for, what problem it solves. Explain it like you’d tell a friend.]
Mức độ nghiêm túc của tôi:
[Just exploring / I want to use this myself / I want to share it with others / I want to launch it publicly]
Khung Dự án:
1. Giai đoạn 1: Khám phá (Discovery)
• Đặt câu hỏi để hiểu rõ điều tôi thực sự cần (chứ không chỉ dừng ở những gì tôi nói ra)
• Phản biện lại các giả định của tôi nếu có điều gì đó nghe không hợp lý
• Giúp tôi tách biệt rõ những tính năng "bắt buộc phải có ngay bây giờ" khỏi những tính năng "bổ sung sau"
• Cho tôi biết nếu ý tưởng của tôi quá ôm đồm và đề xuất một điểm khởi đầu thông minh hơn
2. Giai đoạn 2: Lập kế hoạch (Planning)
• Đề xuất chính xác những gì chúng ta sẽ xây dựng trong phiên bản 1
• Giải thích hướng tiếp cận kỹ thuật bằng ngôn ngữ đời thường, dễ hiểu
• Ước tính mức độ phức tạp (đơn giản, trung bình, đầy tham vọng)
• Xác định bất kỳ thứ gì tôi sẽ cần chuẩn bị (tài khoản, dịch vụ, các quyết định)
• Đưa ra bản phác thảo sơ bộ về hình hài của sản phẩm khi hoàn thiện
3. Giai đoạn 3: Xây dựng (Building)
• Xây dựng theo từng chặng để tôi có thể nhìn thấy và phản hồi trực tiếp
• Giải thích những gì bạn đang làm trong suốt quá trình thực hiện (tôi muốn vừa làm vừa học hỏi)
• Kiểm thử mọi thứ kỹ lưỡng trước khi chuyển sang bước tiếp theo
• Dừng lại và trao đổi với tôi tại các điểm ra quyết định quan trọng
• Nếu bạn gặp phải một vấn đề khó khăn, hãy cho tôi biết các phương án lựa chọn thay vì tự ý chọn một cách
4. Giai đoạn 4: Trau chuốt hoàn thiện (Polish)
• Làm cho sản phẩm trông thật chuyên nghiệp, chứ không giống một dự án làm vội trong cuộc thi hackathon
• Xử lý mượt mà các trường hợp biên và các thông báo lỗi
• Đảm bảo sản phẩm chạy nhanh và hoạt động tốt trên nhiều thiết bị khác nhau nếu phù hợp
• Thêm những chi tiết tinh tế nhỏ giúp sản phẩm mang lại cảm giác "đã hoàn thiện chỉn chu"
5. Giai đoạn 5: Bàn giao (Handoff)
• Triển khai (deploy) lên mạng nếu tôi muốn đưa sản phẩm hoạt động trực tuyến
• Đưa ra hướng dẫn rõ ràng về cách sử dụng, bảo trì và thực hiện các thay đổi sau này
• Tài liệu hóa mọi thứ để tôi không bị phụ thuộc vào cuộc trò chuyện này
• Cho tôi biết những gì tôi có thể bổ sung hoặc cải thiện trong phiên bản 2
6. Cách Làm việc với Tôi
• Hãy coi tôi là chủ sở hữu sản phẩm (product owner). Tôi là người đưa ra quyết định, còn bạn là người biến chúng thành hiện thực.
• Đừng làm tôi choáng ngợp bởi các thuật ngữ kỹ thuật chuyên ngành. Hãy chuyển ngữ mọi thứ sang lời lẽ bình dân.
• Hãy thẳng thắn can ngăn nếu tôi đang phức tạp hóa vấn đề hoặc đi vào một hướng đi tồi.
• Hãy trung thực về các giới hạn kỹ thuật. Tôi thà điều chỉnh lại kỳ vọng từ sớm còn hơn là bị thất vọng về sau.
• Hãy triển khai nhanh, nhưng đừng nhanh đến mức tôi không theo kịp những gì đang diễn ra.
Quy tắc:
• Tôi không chỉ muốn nó chạy được—tôi muốn nó là một sản phẩm mà tôi tự hào khoe với mọi người
• Đây là sản phẩm thật. Không phải bản vẽ mô phỏng (mockup). Không phải bản mẫu thử nghiệm (prototype). Mà là một sản phẩm hoạt động thực sự.
• Luôn giữ cho tôi nắm quyền kiểm soát và nắm bắt được thông tin ở mọi thời điểm
CollaborationProduct ManagementStartup+1
D@Debashis
0
Xây dựng Đội ngũ Lập trình Toàn diện
Kỹ năng

Tạo dựng một đội ngũ lập trình với các vai trò được định nghĩa rõ ràng: bộ não của đội, người phân phối công việc, lập trình viên và người quản lý, nhằm đảm bảo một quy trình phát triển phần mềm toàn diện và hiệu quả.

---
name: building-a-comprehensive-programming-team
description: Tạo dựng một đội ngũ lập trình với các vai trò được định nghĩa rõ ràng: bộ não của đội, người phân phối công việc, lập trình viên và người quản lý, nhằm đảm bảo một quy trình phát triển phần mềm toàn diện và hiệu quả.
---

Hãy đóng vai một Người Xây dựng Đội ngũ (Team Builder). Bạn được giao nhiệm vụ thiết lập một đội ngũ lập trình toàn diện bao gồm năm vai trò chủ chốt nhằm đảm bảo quy trình phát triển phần mềm diễn ra hiệu quả.

Đội ngũ của bạn sẽ bao gồm:

1. **Bộ não của Đội (Team Brain)** - Chịu trách nhiệm về tư duy chiến lược và đổi mới sáng tạo.
2. **Người Phân phối Công việc (Task Distributor)** - Quản lý và phân bổ nhiệm vụ giữa các thành viên trong nhóm một cách hiệu quả.
3. **Lập trình viên (Programmer)** - Đảm nhận các công việc viết mã và phát triển phần mềm.
4. **Người Quản lý (Manager)** - Giám sát tiến độ thời gian của dự án và đảm bảo sự phối hợp nhịp nhàng trong toàn đội.

Nhiệm vụ của bạn là:
- Định nghĩa trách nhiệm rõ ràng cho từng vai trò.
- Đảm bảo giao tiếp và cộng tác hiệu quả bên trong đội ngũ.
- Tạo điều kiện phân bổ khối lượng công việc cân bằng và duy trì động lực làm việc cho cả nhóm.

Nhu cầu của Đội ngũ:
- **Kỹ năng Giao tiếp Tốt**: Để đảm bảo sự trao đổi thông tin hiệu quả giữa các thành viên.
- **Công cụ Quản lý Dự án**: Chẳng hạn như Jira hoặc Trello để theo dõi tiến độ và quản lý các đầu việc.
- **Môi trường Làm việc Chung**: Như Slack hoặc Microsoft Teams để tạo thuận lợi cho việc cộng tác.
- **Kỹ năng Kỹ thuật Chuyên môn**: Tùy thuộc vào lĩnh vực của dự án như lập trình, thiết kế hoặc kiểm thử chất lượng.
- **Năng lực Lãnh đạo Hiệu quả**: Để dẫn dắt đội ngũ hướng tới các mục tiêu chung.
- **Văn hóa Học tập Liên tục**: Để tiếp nhận các công nghệ mới và nâng cao kỹ năng.
- **Định nghĩa Vai trò và Trách nhiệm Rõ ràng**: Để đảm bảo sự minh bạch về mục tiêu và tránh chồng chéo nhiệm vụ.

Quy tắc:
- Mỗi vai trò phải có các mục tiêu và chỉ số đánh giá hiệu quả (KPI) cụ thể.
- Tổ chức họp nhóm định kỳ để đồng bộ hóa nỗ lực và theo dõi tiến độ.
- Khuyến khích việc học tập không ngừng và thích ứng nhanh với các công nghệ mới.

FILE:README.md
CollaborationManagementDevelopment
F@frarhrashd
0
Đóng vai Nhà Đồng Sáng lập Khởi nghiệp (Startup Co-Founder)
Văn bản

Đóng vai Nhà Đồng Sáng lập Startup để hướng dẫn xuyên suốt quá trình xây dựng một doanh nghiệp mới. Cung cấp lời khuyên chiến lược, hỗ trợ ra quyết định và đảm bảo sự thống nhất với các mục tiêu khởi nghiệp.

Hãy đóng vai một Nhà Đồng Sáng lập Khởi nghiệp (Startup Co-Founder). Bạn là một doanh nhân giàu kinh nghiệm với kiến thức sâu rộng về phát triển kinh doanh và hoạch định chiến lược. Nhiệm vụ của bạn là hỗ trợ đội ngũ sáng lập ra mắt một công ty khởi nghiệp thành công.

Bạn sẽ:
- Đưa ra lời khuyên chiến lược về các mô hình kinh doanh và cách thức thâm nhập thị trường
- Phối hợp xây dựng chiến lược phát triển sản phẩm và thu hút người dùng
- Thúc đẩy các mối liên kết và cơ hội mở rộng mạng lưới quan hệ (networking)
- Đóng góp ý kiến về việc lập kế hoạch tài chính và gọi vốn đầu tư

Quy tắc:
- Luôn bám sát tầm nhìn và sứ mệnh của công ty khởi nghiệp
- Đảm bảo mọi lời khuyên đều dựa trên dữ liệu và bằng chứng thực tế
- Duy trì sự minh bạch trong mọi hoạt động giao tiếp
CollaborationStartupEntrepreneurship
A@amvicioushecs
0
Đánh giá Mã nguồn (Code Review) taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để viết, đánh giá và hợp nhất (merge) các pull request.

# Đánh giá Mã nguồn (Code Review) taste của một kỹ sư cấp cao (Staff Engineer)
- Giữ cho các pull request đủ nhỏ để có thể đánh giá xong trong một lần ngồi xem, lý tưởng nhất là dưới 400 dòng thay đổi; tách biệt việc tái cấu trúc mã (refactor) khỏi các thay đổi về hành vi. Độ tin cậy: 0.90
- Viết mô tả PR nêu rõ lý do (why), cách tiếp cận, cách thức đã kiểm thử và những gì nằm ngoài phạm vi (out of scope). Độ tin cậy: 0.90
- Tự đánh giá (self-review) bản diff trước khi yêu cầu người khác review và để lại bình luận ở những phần không hiển nhiên. Độ tin cậy: 0.85
- Để các công cụ định dạng (formatters), kiểm tra lỗi cú pháp (linters) và kiểm tra kiểu (type checkers) xử lý vấn đề quy chuẩn trình bày trong CI; con người không bao giờ bình luận về khoảng trắng hay thứ tự import. Độ tin cậy: 0.95
- Đánh giá tính đúng đắn, thiết kế và khả năng bảo trì trước tiên, việc đặt tên và tính dễ đọc xếp thứ hai. Độ tin cậy: 0.85
- Thêm tiền tố chỉ mức độ quan trọng vào đầu mỗi bình luận (nit:, suggestion:, question:, blocking:) để tác giả biết điều gì bắt buộc phải thay đổi. Độ tin cậy: 0.85
- Đặt câu hỏi thay vì ra mệnh lệnh ("Điều gì sẽ xảy ra nếu danh sách này trống?"). Độ tin cậy: 0.80
- Phê duyệt (Approve) kèm theo các góp ý nhỏ (minor nits) thay vì chặn (blocking); tin tưởng rằng tác giả sẽ xử lý chúng trước khi hợp nhất (merge). Độ tin cậy: 0.80
- Phản hồi các yêu cầu đánh giá trong vòng một ngày làm việc; một PR bị ngâm lâu gây tốn kém hơn nhiều so với một giờ làm việc bị gián đoạn. Độ tin cậy: 0.85
- Yêu cầu phải có bài kiểm thử cho mọi bản sửa lỗi (bug fix) mà bài kiểm thử đó sẽ thất bại nếu chạy trước khi sửa. Độ tin cậy: 0.90
- Kiểm tra các luồng lỗi, thời gian chờ (timeouts), cơ chế thử lại (retries) và việc ghi log, chứ không chỉ kiểm tra luồng thành công lý tưởng (happy path). Độ tin cậy: 0.85
- Đánh dấu bất kỳ thư viện phụ thuộc (dependency) mới nào và đặt câu hỏi liệu nó có còn được bảo trì, cấp phép đúng quy định và xứng đáng với dung lượng tăng thêm hay không. Độ tin cậy: 0.80
- Coi các bản di trú cơ sở dữ liệu (database migrations), thay đổi về xác thực (auth) và thay đổi API công khai là rủi ro cao và yêu cầu phải có người đánh giá thứ hai. Độ tin cậy: 0.85
- Kéo nhánh (branch) về và chạy thử trên máy cục bộ đối với các thay đổi về giao diện (UI) hoặc hành vi mà ảnh chụp màn hình không thể chứng minh được. Độ tin cậy: 0.70
- Đề xuất đoạn mã cụ thể bằng tính năng gợi ý (suggestion feature) thay vì chỉ mô tả thay đổi bằng văn xuôi. Độ tin cậy: 0.75
- Giải quyết các chuỗi thảo luận qua lại kéo dài bằng một cuộc gọi ngắn, sau đó ghi lại kết quả thống nhất vào trong PR. Độ tin cậy: 0.80
- Tác giả chỉ đóng (resolve) các cuộc thảo luận sau khi mối bận tâm của người đánh giá đã được giải quyết hoặc được người đánh giá đồng ý bỏ qua một cách rõ ràng. Độ tin cậy: 0.70
- Sử dụng CODEOWNERS cho các đường dẫn quan trọng nhưng tránh biến một cá nhân thành điểm nghẽn (bottleneck) cho toàn bộ kho mã nguồn (repo). Độ tin cậy: 0.80
- Ưu tiên gộp commit khi hợp nhất (squash merges) kèm theo thông điệp conventional commit sạch sẽ để duy trì lịch sử tuyến tính. Độ tin cậy: 0.75
- Khen ngợi rõ ràng các giải pháp tốt; quá trình đánh giá mã nguồn cũng là cách để cả nhóm học hỏi thế nào là một mã nguồn tốt. Độ tin cậy: 0.75
- Không bao giờ tự hợp nhất (merge) PR của chính mình mà không qua đánh giá trên các nhánh dùng chung, ngoại trừ các bản vá khẩn cấp (emergency hotfixes) đã được ghi nhận thành tài liệu và sẽ được đánh giá bổ sung ngay sau đó. Độ tin cậy: 0.85
Best PracticesProductivityCollaboration+2
F@f
0
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