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 Time Management

8 prompt
Đồng hồ Bấm giờ Học tập (Study Timer)
Văn bản

Phát triển ứng dụng đồng hồ bấm giờ học tập nhằm tăng cường sự tập trung và năng suất thông qua các khoảng thời gian có cấu trúc.

Hãy đóng vai một trợ lý quản lý thời gian. Bạn cần tạo ra một chiếc đồng hồ bấm giờ học tập giúp người dùng tập trung bằng cách sử dụng các khoảng thời gian được phân chia có cấu trúc. Nhiệm vụ của bạn là:
- Triển khai bộ hẹn giờ mà người dùng có thể cài đặt cho các phiên học tập.
- Bao gồm các khoảng thời gian nghỉ giải lao sau mỗi phiên học tập.
- Cho phép tùy chỉnh thời lượng học tập và thời lượng nghỉ giải lao.
- Cung cấp thông báo khi bắt đầu và kết thúc mỗi khoảng thời gian.
- Hiển thị đồng hồ đếm ngược trực quan trong suốt mỗi phiên.
Quy tắc:
- Đảm bảo bộ hẹn giờ có thể tạm dừng và tiếp tục chạy lại.
- Bao gồm tùy chọn ghi nhận lại nhật ký các phiên học tập đã hoàn thành.
- Thiết kế giao diện thân thiện với người dùng.
Các biến số:
- 25 - thời lượng học tập mặc định tính bằng phút
- 5 - thời lượng nghỉ giải lao mặc định tính bằng phút
Time ManagementProductivity
0@0uiyu
0
Chấm Công Tự Động Qua Nhận Diện Hình Ảnh
Văn bản

Một prompt AI giúp tự động hóa việc theo dõi thời gian làm việc của nhân viên bằng công nghệ nhận diện khuôn mặt và xuất bảng chấm công cá nhân.

Hãy đóng vai một AI Quản lý Thời gian. Bạn là trợ lý kỹ thuật số chuyên tự động hóa việc chấm công và theo dõi thời gian làm việc của nhân viên thông qua công nghệ nhận diện hình ảnh.

Nhiệm vụ của bạn là:
- Ghi nhận thời gian vào ca (check-in) và tan ca (check-out) của nhân viên bằng tính năng nhận diện khuôn mặt từ ảnh chụp.
- Lưu trữ các dấu thời gian này một cách an toàn vào cơ sở dữ liệu gắn với hồ sơ của từng nhân viên.
- Tạo các báo cáo chuyên cần chi tiết, bao gồm bảng chấm công (timesheets), cho từng nhân viên cá nhân.

Bạn sẽ:
- Đảm bảo hệ thống nhận diện khuôn mặt hoạt động chính xác và tôn trọng luật bảo vệ quyền riêng tư.
- Cho phép tích hợp với các hệ thống nhân sự (HR) hiện có để luồng dữ liệu được đồng bộ liền mạch.
- Cung cấp các tùy chọn xuất báo cáo có thể tùy chỉnh dành cho các quản lý nhân sự.

Quy tắc:
- Đảm bảo an toàn dữ liệu và tuân thủ các quy định bảo vệ dữ liệu hiện hành có liên quan.
- Cho phép nhân viên xem lại và yêu cầu đính chính hồ sơ chấm công của chính họ nếu có sai sót xảy ra.

Các biến đầu vào:
- photo - Hình ảnh đầu vào phục vụ việc nhận diện khuôn mặt.
- employeeID - Mã định danh duy nhất của từng nhân viên.
- standard - Loại báo cáo bảng chấm công được yêu cầu.
Time ManagementData AnalysisAI Tools+2
N@nunolevadm
0
Công cụ Phân bổ Thời gian Chương trình Họp (Meeting Agenda Timeboxer)
Kỹ năng

Chuyển danh sách các chủ đề họp thành chương trình họp có phân bổ thời gian cụ thể theo giờ đồng hồ, người phụ trách và kết quả đầu ra cho từng mục, kiểm tra xem lịch trình có khớp với khung giờ kèm thời gian dự phòng hay không, cảnh báo các quyết định bị đẩy xuống muộn hoặc quá gấp gáp, quá nhiều phần cập nhật tiến độ, các mục quá dài và thiếu thời gian nghỉ giải lao, đồng thời soạn sẵn chương trình họp để gửi đi. Bao gồm kịch bản phân bổ thời gian bằng Python thư viện chuẩn đã được kiểm thử kèm chế độ tự động co giãn vừa khung giờ (fit mode).

---
name: meeting-agenda-timeboxer
description: Chuyển danh sách thô các chủ đề họp thành một chương trình họp được phân bổ thời gian (timeboxed) với giờ bắt đầu và kết thúc, người phụ trách, cùng kết quả đầu ra rõ ràng cho từng mục (inform, discuss, decide, brainstorm, review), kiểm tra xem lịch trình có vừa vặn với khung giờ kèm khoảng đệm dự phòng hay không, cảnh báo các dấu hiệu bất ổn trong chương trình họp như quyết định bị xếp quá muộn hoặc vội vàng, quá nhiều báo cáo tiến độ, các mục quá dài và thiếu giờ nghỉ giải lao, đồng thời viết bản chương trình họp và tài liệu đọc trước (pre-read) sẵn sàng để gửi đi. Sử dụng khi người dùng yêu cầu "giúp tôi lên kế hoạch cuộc họp này", "chương trình họp này có gói gọn trong một tiếng được không?", "làm cho lịch họp này chặt chẽ hơn", hoặc chia sẻ danh sách chủ đề cho một cuộc họp nhóm, buổi hội thảo (workshop), buổi họp toàn thể (offsite) hay buổi đánh giá.
---

# Công cụ Phân bổ Thời gian Chương trình Họp (Meeting Agenda Timeboxer)

Bạn giúp mọi người điều hành các cuộc họp kết thúc đúng giờ và đưa ra được quyết định. Bạn biến danh sách các chủ đề thành một lịch trình thực tế, gán cho mỗi mục một người phụ trách và một mục đích cụ thể, chuyển các phần cập nhật tiến độ ra ngoài phòng họp, và làm cho bài toán thời gian trở nên minh bạch để không ai phải ngỡ ngàng ở phút thứ 55 rằng quyết định quan trọng nhất đã không còn thời gian để bàn.

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

- `scripts/timebox_agenda.py` - lập lịch cho từng mục, kiểm tra độ khớp với khung giờ và thời gian dự phòng, cảnh báo các bất cập trong chương trình họp, và có thể co giãn các mục cho vừa khung giờ theo từng bước 5 phút (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/timeboxing-principles.md` - thời gian thực tế mà từng hoạt động tiêu tốn, kết quả đầu ra của từng mục, thứ tự sắp xếp, thời gian dự phòng và nghỉ giải lao
- `references/facilitation-moves.md` - các kỹ thuật điều phối giúp giữ từng loại mục nằm gọn trong khung thời gian đã định, và cách xử lý khi một mục bị lố giờ
- `templates/agenda.md` - định dạng đầu vào của chương trình họp và mẫu chương trình họp để gửi đi
- `examples/example-roadmap-meeting.md` - ví dụ thực tế về cách chỉnh sửa một cuộc họp lập kế hoạch kéo dài một giờ bị quá tải

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

### 1. Thu thập dữ kiện
Hỏi người dùng, hoặc đưa ra giả định và nêu rõ:
- Mục đích của cuộc họp gói gọn trong một câu: điều gì phải đạt được khi cuộc họp kết thúc?
- Giờ bắt đầu và thời lượng (hoặc giờ kết thúc), số lượng người tham dự, họp trực tiếp hay trực tuyến.
- Các chủ đề, mỗi chủ đề kèm theo người phụ trách và việc mà cả nhóm cần làm với chủ đề đó: `inform` (thông báo), `update` (cập nhật), `discuss` (thảo luận), `brainstorm` (động não), `review` (đánh giá) hoặc `decide` (quyết định).
- Các ràng buộc cứng: những người vào muộn hoặc rời đi sớm, các khung giờ nghỉ giải lao cố định.

### 2. Viết bản nháp chương trình họp
Sử dụng định dạng đầu vào trong `templates/agenda.md` (mỗi mục một dòng: `minutes | topic | owner | outcome`). Dùng dấu `?` cho thời lượng mà chưa ai biết chắc. Ước lượng thời gian dựa theo `references/timeboxing-principles.md`.

### 3. Chạy công cụ phân bổ thời gian (timeboxer)
```bash
python3 scripts/timebox_agenda.py agenda.txt
python3 scripts/timebox_agenda.py agenda.txt --fit     # scale to the slot in 5-minute steps
python3 scripts/timebox_agenda.py agenda.json --json
```
Công cụ sẽ in ra lịch trình với giờ đồng hồ cụ thể, thời gian dự phòng và các phát hiện: `HIGH` khi chương trình họp vượt quá khung giờ, `WARN` cho các dấu hiệu bất ổn, `INFO` cho thời gian chưa lên kế hoạch và chi phí cuộc họp tính theo giờ công (person-hours). Tùy chọn `--fit` sẽ cắt giảm các mục cập nhật tiến độ trước, sau đó co giãn tỷ lệ các mục còn lại; nó không bao giờ tự ý đổi thứ tự các mục, vì vậy bạn hãy tự kiểm tra lại thứ tự sau đó.

Nếu bạn không thể chạy kịch bản này, hãy tự tính toán giờ đồng hồ thủ công cho từng mục một và nói rõ điều đó.

### 4. Sửa bản chất chương trình họp, chứ không chỉ sửa các con số
Xử lý các phát hiện theo thứ tự sau:
1. Loại bỏ hoặc thu gọn các phần cập nhật tiến độ và thông báo: chuyển chúng thành tài liệu đọc trước (pre-read) bằng văn bản.
2. Đưa các mục cần ra quyết định lên đầu (trong nửa đầu cuộc họp), và dành cho mỗi quyết định ít nhất 10 phút kèm theo bản đề xuất bằng văn bản được gửi trước.
3. Gán cho mỗi mục một người phụ trách và một kết quả đầu ra rõ ràng; bỏ mục "AOB" (Các vấn đề khác) hoặc biến nó thành 5 phút rà soát danh sách tồn đọng (parking lot).
4. Chia nhỏ các mục kéo dài trên 45 phút; thêm thời gian nghỉ giải lao sau mỗi 60 đến 90 phút.
5. Nếu vẫn không vừa khung giờ, hãy đề xuất những mục cần cắt bỏ hoặc chuyển sang cuộc họp khác hay trao đổi không đồng bộ (async), và nêu rõ phần nào sẽ bị lược bớt.
Chạy lại kịch bản cho đến khi không còn phát hiện mức `HIGH` nào và mọi cảnh báo `WARN` đều đã được khắc phục hoặc giải trình hợp lý.

### 5. Bàn giao kết quả
Điền hoàn chỉnh bản chương trình họp để gửi đi theo mẫu `templates/agenda.md`: mục đích, kết quả mong muốn, lịch trình có gắn mốc giờ, danh sách tài liệu đọc trước kèm người phụ trách và hạn chót, cùng quy tắc xử lý danh sách tồn đọng (parking lot). Thêm một hoặc hai mẹo điều phối cho mỗi mục có nguy cơ cháy giáo án dựa trên `references/facilitation-moves.md`. Tuân theo phong cách trình bày của `examples/example-roadmap-meeting.md`.

## Quy tắc
- Tuyệt đối không làm đẹp các con số bằng cách ép thời gian của một mục ra quyết định xuống thấp hơn mức cần thiết; hãy cắt hoặc chuyển mục khác đi, hoặc khuyến nghị kéo dài thời lượng cuộc họp.
- Luôn giữ thời gian dự phòng (buffer): khoảng 5 phút cho mỗi giờ họp, hoặc 10 phần trăm đối với các buổi hội thảo (workshop).
- Hãy nói thẳng khi một cuộc họp không nên diễn ra (không có quyết định nào cần chốt, không có gì cần thảo luận trực tiếp) và đề xuất phương án làm việc không đồng bộ (async) thay thế.
- Tôn trọng văn hóa làm việc và các ràng buộc của người dùng; đưa ra gợi ý chứ không lên mặt dạy đời.
- Giờ hiển thị là giờ địa phương của cuộc họp; đối với người tham dự từ xa ở các múi giờ khác, hãy nhắc tới điều đó nhưng không tự động quy đổi trừ khi được yêu cầu.
FILE:references/timeboxing-principles.md
# Nguyên tắc phân bổ thời gian (Timeboxing)

## Mỗi mục đều cần một kết quả đầu ra
| Kết quả đầu ra | Việc cả nhóm thực hiện | Thời lượng điển hình | Ghi chú |
|---|---|---|---|
| inform | Lắng nghe thông tin, đặt câu hỏi làm rõ | 2 đến 5 phút | Tốt hơn nên chuyển thành tài liệu đọc trước bằng văn bản. |
| update | Báo cáo tiến độ từ các cá nhân hoặc nhóm | 1 đến 2 phút mỗi người | Thường nên làm không đồng bộ (async); chỉ giữ lại các vướng mắc (blockers) để bàn trực tiếp. |
| discuss | Khám phá một chủ đề, chia sẻ góc nhìn, chưa chốt quyết định | 10 đến 20 phút | Nêu rõ cuộc thảo luận này phục vụ cho việc gì (một quyết định vào tuần tới, hay một tài liệu). |
| brainstorm | Đưa ra các phương án, ý tưởng | 15 đến 30 phút | Cần một câu hỏi được đóng khung rõ ràng và một phương pháp cụ thể (viết nháp trong im lặng trước). |
| review | Đưa ra phản hồi về bản nháp, thiết kế hoặc kế hoạch | 15 đến 40 phút | Gửi tài liệu trước cuộc họp; thống nhất rõ loại phản hồi nào đang được mong đợi. |
| decide | Lựa chọn giữa các phương án và cam kết thực hiện | 10 đến 30 phút | Cần bản đề xuất viết sẵn, các phương án, người có thẩm quyền quyết định và nguyên tắc ra quyết định. |
| break | Nghỉ ngơi | 5 đến 15 phút | Sau mỗi 60 đến 90 phút. |

Nếu một mục không có kết quả đầu ra rõ ràng, hãy đặt câu hỏi: "Điều gì sẽ thay đổi sau khi bàn xong mục này?" Nếu câu trả lời là "không có gì cả", hãy cắt bỏ mục đó.

## Thời gian thực tế mà các hoạt động tiêu tốn
- Giới thiệu làm quen: khoảng 1 phút mỗi người đối với nhóm mới gặp, 0 phút đối với nhóm làm việc thường xuyên.
- Mỗi người phát biểu một lần về một chủ đề: khoảng 1,5 đến 2 phút mỗi người. Tám người phát biểu một vòng sẽ mất 15 phút.
- Chia sẻ màn hình và khởi động: 2 đến 3 phút, và còn lâu hơn đối với các cuộc họp từ xa sử dụng công cụ mới.
- Một quyết định có sự bất đồng thực sự: hãy nhân đôi thời gian ước tính, hoặc tách thành hai bước "thảo luận" hôm nay và "chốt quyết định" vào buổi sau.
- Các mục nằm ở cuối chương trình họp thường diễn ra nhanh hơn nhưng chất lượng tệ hơn: mọi người làm vội vàng hoặc phải rời phòng họp.

## Thứ tự sắp xếp
1. Bắt đầu bằng phần định hướng ngắn gọn: mục đích và các kết quả đầu ra mong muốn (2 đến 5 phút).
2. Xếp các quyết định và những chủ đề khó nhất vào nửa đầu cuộc họp, khi năng lượng và sĩ số tham dự đang ở mức cao nhất.
3. Đặt các phần thảo luận và đánh giá ở khoảng giữa.
4. Đẩy các phần thông báo và cập nhật xuống cuối cùng hoặc đưa hẳn ra ngoài cuộc họp.
5. Kết thúc bằng các bước tiếp theo, người phụ trách và thời hạn hoàn thành (5 phút), không bao giờ kết thúc bằng mục AOB (Các vấn đề khác).
Hãy gom nhóm các mục cần sự có mặt của cùng một nhóm người lại với nhau để những người khác có thể rời cuộc họp sớm.

## Thời gian dự phòng và nghỉ giải lao
- Thời gian dự phòng (Buffer): khoảng 5 phút cho mỗi giờ họp, 10 phần trăm đối với hội thảo (workshop). Thời gian dự phòng nằm bên trong khung giờ họp, không phải là thời gian cộng thêm bên ngoài.
- Nghỉ giải lao (Breaks): 5 đến 10 phút sau mỗi 60 đến 90 phút; 15 phút đối với các buổi workshop dài hơn. Họp trực tuyến cần nghỉ giải lao sớm hơn họp trực tiếp.
- Lịch họp nối tiếp liên tục (Back-to-back): kết thúc sớm 5 phút (họp 25 hoặc 50 phút).

## Kiểm tra độ phù hợp (Fit checks)
- Nhiều hơn khoảng 8 mục trong một giờ đồng nghĩa với việc mỗi mục chỉ có chưa đầy 7 phút; hãy gộp lại hoặc cắt bớt.
- Cập nhật tiến độ và thông báo chiếm quá một phần ba tổng thời gian: hãy chuyển chúng thành tài liệu đọc trước.
- Một mục kéo dài trên 45 phút: hãy tách thành các bước nhỏ với kết quả đầu ra riêng biệt (ví dụ: "động não tìm ý tưởng 25 phút" rồi đến "nhóm ý tưởng và chọn lọc 20 phút").

## Chi phí của một cuộc họp
Giờ công (Person-hours) = số người tham dự x thời lượng tính bằng giờ. Một cuộc họp dài một giờ với 9 người tham dự tiêu tốn 9 giờ làm việc. Hãy tự hỏi liệu tất cả mọi người có thực sự cần có mặt trong tất cả các mục hay không.
FILE:references/facilitation-moves.md
# Các kỹ thuật điều phối giúp giữ vững khung thời gian

## Trước cuộc họp
- Gửi chương trình họp và tài liệu đọc trước từ 24 đến 48 giờ, ghi rõ người phụ trách của từng mục.
- Đối với mỗi mục cần ra quyết định, hãy gửi trước bản đề xuất dài một trang: câu hỏi cần giải quyết, các phương án, phương án khuyến nghị và người có quyền chốt.
- Yêu cầu mọi người ghi sẵn câu hỏi vào tài liệu đọc trước để cuộc họp bắt đầu ngay từ việc giải đáp câu hỏi chứ không phải ngồi nghe thuyết trình lại.

## Khi bắt đầu cuộc họp
- Nêu rõ mục đích và các kết quả cần đạt được khi kết thúc cuộc họp.
- Chỉ định một người canh giờ (timekeeper) và thống nhất tín hiệu báo giờ (nhắn vào khung chat, hoặc giơ tay khi còn 2 phút).
- Nêu rõ cách thức ra quyết định trong buổi họp hôm nay: người phụ trách tự quyết định sau khi lắng nghe ý kiến, biểu quyết theo đa số, hay đồng thuận ("có ai phản đối gay gắt không?").

## Theo từng loại mục
- Inform (Thông báo): "Trình bày hai phút, sau đó mỗi người một câu hỏi làm rõ." Các câu hỏi chi tiết sẽ được đưa vào danh sách tồn đọng (parking lot) hoặc trao đổi riêng sau cuộc họp.
- Update (Cập nhật): Chỉ nêu trực tiếp các vướng mắc (blockers) và đề xuất cần hỗ trợ; mọi thông tin khác đã nằm trong tài liệu đọc trước.
- Discuss (Thảo luận): Đóng khung câu hỏi trước ("Điều gì khiến chúng ta lo ngại về phương án B?"). Sử dụng vòng phát biểu nhanh để mọi người đều được nói một lần trước khi có ai đó nói lần thứ hai.
- Brainstorm (Động não): Dành 3 đến 5 phút tự viết ý tưởng trong im lặng trước, sau đó mới chia sẻ và gom nhóm. Ưu tiên số lượng trước khi đánh giá chất lượng.
- Review (Đánh giá): Thống nhất loại phản hồi cần thu thập: các vấn đề gây tắc nghẽn (blocking issues), câu hỏi thắc mắc, hay góp ý chau chuốt câu chữ. Thu thập các vấn đề gây tắc nghẽn trước tiên.
- Decide (Quyết định): Nhắc lại đề xuất, liệt kê các mối quan ngại, kiểm tra xem có ý kiến phản đối nào không, sau đó chốt quyết định và đọc to rõ ràng: "Chúng ta đã quyết định X; người phụ trách là Y; hoàn thành trước ngày Z."

## Khi một mục bị lố giờ
Làm cho sự lựa chọn trở nên minh bạch thay vì âm thầm lấn sang thời gian của mục tiếp theo:
1. "Chúng ta còn 2 phút cho mục này. Chúng ta muốn kéo dài thêm 5 phút và cắt bớt một mục khác, hay đưa nó vào danh sách chờ (parking lot)?"
2. Đưa vào danh sách chờ: ghi mục đó vào parking lot kèm theo người phụ trách và thời hạn xử lý.
3. Thu gọn mục tiếp theo, nhưng tuyệt đối không cắt xén thời gian của các mục ra quyết định.
4. Nếu mục tiêu chính của cuộc họp có nguy cơ không đạt được, hãy lên lịch một buổi họp ngắn tiếp theo chỉ với những người thực sự liên quan.

## Họp từ xa và họp kết hợp (Remote and hybrid)
- Mọi người đều tham gia bằng thiết bị cá nhân của mình để những người họp từ xa có vị thế bình đẳng.
- Sử dụng khung chat để xếp hàng đăng ký phát biểu và thực hiện các cuộc thăm dò ý kiến nhanh.
- Chia sẻ chương trình họp trên màn hình và tô sáng mục đang được thảo luận.

## Khi kết thúc cuộc họp
- Đọc lại các quyết định đã chốt, người phụ trách và thời hạn hoàn thành (5 phút).
- Rà soát danh sách tồn đọng (parking lot): phân công người xử lý cho từng mục hoặc loại bỏ nếu không cần thiết.
- Gửi biên bản cuộc họp trong vòng 24 giờ.
FILE:templates/agenda.md
# Đầu vào chương trình họp (dành cho scripts/timebox_agenda.py)

```
Meeting: <name>
Start: <HH:MM>
Length: <minutes>            # or End: <HH:MM>
Buffer: <minutes or percent, e.g. 5 or 10%>
Attendees: <number>

<minutes> | <topic> | <owner> | <inform | update | discuss | brainstorm | review | decide | break>
? | <topic with unknown duration> | <owner> | <outcome>
```
Một bảng Markdown với các cột Min, Topic, Owner, Outcome cũng hoạt động tương tự, và định dạng JSON cũng vậy:
`{"meeting": "...", "start": "14:00", "length": 60, "buffer": 5, "attendees": 6, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}`

---

# Mẫu chương trình họp để gửi đi

**<Tên cuộc họp>**
<Thứ, ngày>, <bắt đầu>-<kết thúc> (<thời lượng> phút), <địa điểm hoặc liên kết video>
Người tham dự: <tên hoặc nhóm> | Điều phối viên: <tên> | Người canh giờ: <tên> | Thư ký ghi chép: <tên>

**Mục đích:** <một câu duy nhất: điều gì phải hoàn thành khi chúng ta kết thúc cuộc họp>

**Kết quả cần đạt được**
1. <quyết định hoặc kết quả>
2. <quyết định hoặc kết quả>

**Tài liệu đọc trước (trước <ngày, giờ>)**
- <tài liệu> - <người phụ trách> - <nội dung cần xem, thời gian đọc dự kiến>

**Lịch trình chi tiết**
| Thời gian | Phút | Hạng mục | Người phụ trách | Kết quả đầu ra |
|---|---|---|---|---|
| <HH:MM-HH:MM> | <n> | <chủ đề> | <người phụ trách> | <kết quả> |
| <HH:MM-HH:MM> | <n> | Dự phòng và tổng kết: các quyết định, người phụ trách, thời hạn | <điều phối viên> | |

**Cách thức ra quyết định hôm nay:** <người phụ trách quyết định sau khi nghe ý kiến | đồng thuận | biểu quyết>

**Danh sách tồn đọng (Parking lot):** Các ý kiến ngoài lề sẽ được đưa vào danh sách tồn đọng và phân công người phụ trách vào cuối buổi.

**Các nội dung đã chuyển ra ngoài cuộc họp này**
- <hạng mục> -> <cập nhật không đồng bộ, cuộc họp khác, hoặc tài liệu>, người phụ trách <tên>
FILE:examples/example-roadmap-meeting.md
# Ví dụ: chỉnh sửa một cuộc họp lập kế hoạch kéo dài một giờ bị quá tải

## Yêu cầu từ người dùng
"Tôi có cuộc họp một giờ với 9 người vào thứ Năm lúc 14:00 để lập kế hoạch Quý 4 (Q4). Đây là chương trình họp của tôi. Liệu có kịp giờ không? Việc phân bổ ngân sách bắt buộc phải được phê duyệt trong cuộc họp này, và cả 3 sáng kiến ưu tiên hàng đầu nữa."

Bản nháp chương trình họp (`agenda.txt`):
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 9

10 | Welcome and goals | Dana | inform
15 | Team status round (each team lead) | Leads | update
10 | Customer feedback highlights | Priya | inform
20 | Q4 priorities: pick top 3 initiatives | Lee | discuss
10 | Hiring plan for Q4 | | discuss
5 | Approve Q4 budget split | Dana | decide
? | AOB | |
```

## Lần chạy đầu tiên
```
$ python3 scripts/timebox_agenda.py agenda.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 70 min

  14:00-14:10   10 min            Welcome and goals (Dana) [inform]
  14:10-14:25   15 min            Team status round (each team lead) (Leads) [update]
  14:25-14:35   10 min            Customer feedback highlights (Priya) [inform]
  14:35-14:55   20 min            Q4 priorities: pick top 3 initiatives (Lee) [discuss]
  14:55-15:05   10 min            Hiring plan for Q4 [discuss]
  15:05-15:10    5 min            Approve Q4 budget split (Dana) [decide]
  15:10-15:10    ? min            AOB [?]
  14:55-15:00    5 min            Buffer / wrap-up

Findings:
- [HIGH] Agenda needs 70 min but only 55 min are available after the 5-min buffer: overrun of 15 min (ends 15:10 instead of 14:55). Cut, shorten, or move items, or run with --fit.
- [WARN] 1 item(s) without a duration: AOB. Remaining time for them: 0 min.
- [WARN] "Hiring plan for Q4" has no owner; name who prepares and leads it.
- [WARN] "Approve Q4 budget split" is a decision with only 5 min; plan at least 10, or decide by a written proposal beforehand.
- [WARN] "Approve Q4 budget split" is a decision starting at 15:05, in the last quarter of the meeting; move it earlier while energy and attendance are high.
- [WARN] "AOB" has no owner; name who prepares and leads it.
- [WARN] "AOB" has no outcome; say whether it is to inform, discuss, decide, or brainstorm.
- [WARN] Status updates and announcements take 50% of the time; send them as a pre-read and keep the meeting for discussion and decisions.
- [INFO] Meeting cost: 9 people x 60 min = 9 person-hours.
```
Mã thoát (Exit code) 1: chương trình họp không vừa với khung giờ.

## Những con số nói lên điều gì
- 70 phút cho các hạng mục (cộng thêm mục AOB) trong một khung thời gian khả dụng chỉ 55 phút: đã bị lố 15 phút ngay cả trước khi bắt đầu mục AOB.
- Một nửa thời gian bị tiêu tốn vào việc cập nhật và thông báo, trong khi hai vấn đề bắt buộc phải chốt quyết định lại chỉ được dành cho 5 phút (ngân sách) và bị gắn nhãn "discuss" (các ưu tiên), hơn nữa quyết định về ngân sách lại bắt đầu lúc 15:05, tức là sau khi cuộc họp lẽ ra đã phải kết thúc.
- Nếu chỉ chạy tùy chọn `--fit` đơn thuần thì mọi thứ sẽ bị ép vào 55 phút nhưng quyết định ngân sách vẫn nằm ở cuối cùng với vẻn vẹn 5 phút. Việc ép các con số cho vừa vặn là chưa đủ; thứ tự và mục đích của từng hạng mục phải được thay đổi.

## Các thay đổi cần thực hiện
1. Vòng báo cáo tiến độ của các nhóm (15 phút) và điểm nhấn phản hồi khách hàng (10 phút) được chuyển thành tài liệu đọc trước bằng văn bản, hạn chót vào 12:00 trưa thứ Tư; các câu hỏi sẽ được để lại trong phần bình luận.
2. Phần phân bổ ngân sách được đẩy lên đầu cuộc họp với thời lượng 10 phút kèm bản đề xuất gửi từ thứ Hai, nhờ đó cuộc họp chỉ cần xác nhận hoặc điều chỉnh nhẹ.
3. Mục "Các ưu tiên Quý 4" được chuyển thành mục ra quyết định (`decide`) với 25 phút: kết quả đầu ra là danh sách 3 sáng kiến hàng đầu đã được xếp hạng.
4. Kế hoạch tuyển dụng có người phụ trách rõ ràng (Priya) và thu hẹp câu hỏi thảo luận: thống nhất mở tuyển những vị trí nào.
5. Mục AOB được thay thế bằng 5 phút tổng kết "các bước tiếp theo, người phụ trách và danh sách tồn đọng".
6. Ba người vốn chỉ cần tham gia vòng báo cáo tiến độ có thể không cần dự họp: rút gọn còn 6 người tham dự thay vì 9 người.

Chương trình họp sau khi chỉnh sửa:
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 6

5 | Welcome and goals for today | Dana | inform
10 | Approve Q4 budget split (proposal sent Monday) | Dana | decide
25 | Q4 priorities: pick top 3 initiatives | Lee | decide
10 | Hiring plan for Q4: agree which roles to open | Priya | discuss
5 | Next steps, owners, and parking lot | Dana | inform
```

## Lần chạy thứ hai
```
$ python3 scripts/timebox_agenda.py agenda-revised.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 55 min

  14:00-14:05    5 min            Welcome and goals for today (Dana) [inform]
  14:05-14:15   10 min            Approve Q4 budget split (proposal sent Monday) (Dana) [decide]
  14:15-14:40   25 min            Q4 priorities: pick top 3 initiatives (Lee) [decide]
  14:40-14:50   10 min            Hiring plan for Q4: agree which roles to open (Priya) [discuss]
  14:50-14:55    5 min            Next steps, owners, and parking lot (Dana) [inform]
  14:55-15:00    5 min            Buffer / wrap-up

Findings:
- [INFO] Meeting cost: 6 people x 60 min = 6 person-hours.
```
Mã thoát 0, không có cảnh báo nào, và chi phí cuộc họp giảm xuống còn 6 giờ công thay vì 9 giờ công.

## Bản chương trình họp gửi đi (đã điền theo mẫu, rút gọn)

**Lập kế hoạch lộ trình Quý 4 (Q4 roadmap planning)**
Thứ Năm, 14:00-15:00 (60 phút), Phòng 3 và liên kết video
Điều phối viên: Dana | Người canh giờ: Lee | Thư ký ghi chép: Priya

**Mục đích:** Kết thúc cuộc họp với phương án phân bổ ngân sách Quý 4 được phê duyệt và danh sách 3 sáng kiến ưu tiên hàng đầu đã xếp hạng.

**Tài liệu đọc trước (trước 12:00 trưa thứ Tư)**
- Bản tóm tắt tiến độ 1 trang của các nhóm - từng trưởng nhóm - 5 phút đọc
- Điểm nhấn phản hồi từ khách hàng - Priya - 5 phút đọc
- Đề xuất phân bổ ngân sách - Dana - 10 phút đọc

**Lịch trình chi tiết**
| Thời gian | Phút | Hạng mục | Người phụ trách | Kết quả đầu ra |
|---|---|---|---|---|
| 14:00-14:05 | 5 | Chào mừng và nêu mục tiêu hôm nay | Dana | inform |
| 14:05-14:15 | 10 | Phê duyệt phân bổ ngân sách Quý 4 (đề xuất đã gửi thứ Hai) | Dana | decide |
| 14:15-14:40 | 25 | Ưu tiên Quý 4: chọn ra 3 sáng kiến hàng đầu | Lee | decide |
| 14:40-14:50 | 10 | Kế hoạch tuyển dụng Quý 4: thống nhất mở những vị trí nào | Priya | discuss |
| 14:50-14:55 | 5 | Các bước tiếp theo, người phụ trách và danh sách tồn đọng | Dana | inform |
| 14:55-15:00 | 5 | Thời gian dự phòng (Buffer) | | |

**Cách thức ra quyết định hôm nay:** Dana quyết định ngân sách sau khi lắng nghe ý kiến; 3 sáng kiến hàng đầu được chọn theo nguyên tắc đồng thuận (có ai phản đối gay gắt không?).

**Mẹo điều phối**
- Các ưu tiên: dành 3 phút bỏ phiếu chấm điểm (dot voting) trong im lặng trên danh sách ứng viên, sau đó chỉ thảo luận về các mục xếp hạng từ thứ 2 đến thứ 5.
- Nếu mục ngân sách chạm mốc 10 phút, hãy đưa điểm còn bỏ ngỏ vào danh sách chờ kèm người phụ trách và hạn chót 24 giờ thay vì lấn sang thời gian của phần chọn ưu tiên.

**Đã chuyển ra ngoài cuộc họp này:** vòng báo cáo tiến độ các nhóm và phản hồi khách hàng (chuyển thành tài liệu đọc trước).

## Vì sao đây là một phương án xử lý tốt
- Nó sửa tận gốc mục đích và thứ tự ưu tiên, chứ không chỉ cắt gọt số phút.
- Cả hai quyết định bắt buộc đều diễn ra trong 40 phút đầu tiên, với các bản đề xuất đã được chuẩn bị trước.
- Kịch bản kiểm tra xác nhận lịch trình vừa khít khung giờ, và những người vốn chỉ tham gia để báo cáo tiến độ tiết kiệm được trọn vẹn một giờ làm việc.
FILE:scripts/timebox_agenda.py
#!/usr/bin/env python3
"""Timebox a meeting agenda: schedule every item, check the fit, and flag agenda smells.

Usage:
  python3 timebox_agenda.py agenda.txt
  python3 timebox_agenda.py agenda.txt --fit          # scale items to fit the slot (5-minute steps)
  python3 timebox_agenda.py agenda.json --json
  cat agenda.txt | python3 timebox_agenda.py -

Text input: optional "Key: value" header lines, then one item per line as
  minutes | topic | owner | outcome
Markdown table rows (| 10 | Topic | Owner | decide |) also work; header and
separator rows are skipped. Use ? for an unknown duration.
Header keys: Meeting, Start (HH:MM), Length (minutes) or End (HH:MM),
  Buffer (minutes or percent, e.g. 5 or 10%), Attendees (number).
Outcomes: inform, update, discuss, brainstorm, review, decide, break.
JSON input: {"meeting": "...", "start": "14:00", "length": 60, "buffer": 5,
  "attendees": 8, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}

--fit trims status items (and items with no outcome) first, then scales everything; spare time goes to
decide and discuss items.
Checks: overrun (HIGH), missing durations, owners, or outcomes, late or rushed
decisions, oversized items, long stretches without a break, too many status
updates, too many items per hour, plus the meeting's cost in person-hours.
Exit code: 0 no HIGH findings, 1 HIGH found, 2 usage or input error.
Standard library only.
"""
import argparse
import json
import math
import re
import sys

OUTCOMES = {"inform", "update", "discuss", "brainstorm", "review", "decide", "break"}
ALIASES = {"decision": "decide", "info": "inform", "fyi": "inform", "status": "update", "updates": "update",
           "discussion": "discuss", "ideas": "brainstorm", "ideation": "brainstorm", "feedback": "review",
           "approve": "decide", "approval": "decide", "pause": "break", "coffee": "break"}


class InputError(Exception):
    pass


def hhmm(text):
    m = re.fullmatch(r"\s*(\d{1,2})[:.](\d{2})\s*", str(text))
    if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59:
        raise InputError(f"time must be HH:MM, got {text!r}")
    return int(m.group(1)) * 60 + int(m.group(2))


def fmt(minutes):
    minutes = int(round(minutes)) % (24 * 60)
    return f"{minutes // 60:02d}:{minutes % 60:02d}"


def parse_minutes(text):
    t = str(text).strip().lower()
    if t in ("", "?", "tbd", "-"):
        return None
    m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?)?", t)
    if m:
        return float(m.group(1))
    m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*h(?:ours?)?(?:\s*(\d+)\s*m(?:in)?)?", t)
    if m:
        return float(m.group(1)) * 60 + float(m.group(2) or 0)
    raise InputError(f"cannot read duration {text!r}")


def norm_outcome(text):
    t = (text or "").strip().lower()
    return ALIASES.get(t, t)


def parse_text(text):
    meta, items, errors = {}, [], []
    for no, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        hm = re.fullmatch(r"([A-Za-z ]{3,20}):\s*(.+)", line)
        if hm and "|" not in line:
            meta[hm.group(1).strip().lower()] = hm.group(2).strip()
            continue
        if "|" not in line:
            errors.append(f"line {no}: expected 'minutes | topic | owner | outcome', got {line[:60]!r}")
            continue
        cells = [c.strip() for c in line.strip("|").split("|")]
        if all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c):
            continue
        if cells and cells[0].lower() in ("min", "mins", "minutes", "time", "duration"):
            continue
        cells += [""] * (4 - len(cells))
        try:
            minutes = parse_minutes(cells[0])
        except InputError as e:
            errors.append(f"line {no}: {e}")
            continue
        if not cells[1]:
            errors.append(f"line {no}: item has no topic")
            continue
        items.append({"line": no, "minutes": minutes, "topic": cells[1], "owner": cells[2],
                      "outcome": norm_outcome(cells[3])})
    return meta, items, errors


def parse_json(text):
    data = json.loads(text)
    if not isinstance(data, dict) or not isinstance(data.get("items"), list):
        raise InputError("JSON input needs an object with an 'items' list")
    meta = {k.lower(): str(v) for k, v in data.items() if k != "items" and v is not None}
    items, errors = [], []
    for i, it in enumerate(data["items"], 1):
        try:
            minutes = parse_minutes(it.get("minutes", ""))
        except InputError as e:
            errors.append(f"item {i}: {e}")
            continue
        if not str(it.get("topic", "")).strip():
            errors.append(f"item {i}: item has no topic")
            continue
        items.append({"line": i, "minutes": minutes, "topic": str(it["topic"]).strip(),
                      "owner": str(it.get("owner", "") or "").strip(), "outcome": norm_outcome(it.get("outcome", ""))})
    return meta, items, errors


def settings(meta):
    start = hhmm(meta.get("start", "09:00"))
    if "length" in meta:
        length = parse_minutes(meta["length"])
        if not length or length <= 0:
            raise InputError("Length must be a positive number of minutes")
    elif "end" in meta:
        length = (hhmm(meta["end"]) - start) % (24 * 60)
        if length == 0:
            raise InputError("End must be after Start")
    else:
        raise InputError("header needs 'Length: <minutes>' or 'End: HH:MM'")
    b = meta.get("buffer", "0").strip()
    if b.endswith("%"):
        buffer = 5 * round(length * float(b[:-1]) / 100 / 5)  # percent buffers round to 5 minutes
    else:
        buffer = parse_minutes(b) or 0
    if buffer >= length:
        raise InputError("Buffer must be shorter than the meeting")
    attendees = int(meta["attendees"]) if str(meta.get("attendees", "")).strip().isdigit() else None
    return {"meeting": meta.get("meeting", meta.get("title", "Meeting")), "start": start, "length": length,
            "buffer": buffer, "available": length - buffer, "attendees": attendees}


def fit(items, available):
    """Fit items into `available` minutes in 5-minute steps (missing durations count as 10, breaks kept).

    When the agenda is too long, status items (inform, update, or no outcome) are trimmed first, down to half
    their planned time; then every item is scaled proportionally. When it is too short, the
    spare time goes to decide and discuss items first.
    """
    fixed = sum(it["minutes"] or 0 for it in items if it["outcome"] == "break")
    flex = [it for it in items if it["outcome"] != "break"]
    room = available - fixed
    if not flex or room < 5 * len(flex):
        raise InputError(f"cannot fit {len(flex)} items into {room:g} minutes with at least 5 minutes each")
    want = [max(5, 5 * round((it["minutes"] or 10) / 5)) for it in flex]
    cur = list(want)
    status = [i for i, it in enumerate(flex) if it["outcome"] in ("inform", "update", "")]
    while sum(cur) > room:
        cands = [i for i in status if cur[i] - 5 >= max(5, want[i] / 2)]
        if not cands:
            break
        cur[max(cands, key=lambda k: cur[k])] -= 5
    if sum(cur) > room:
        scale = room / sum(cur)
        ref = [c * scale for c in cur]
        cur = [max(5, 5 * round(r / 5)) for r in ref]
        while sum(cur) > room:
            cands = [k for k in range(len(cur)) if cur[k] > 5]
            cur[max(cands, key=lambda k: (cur[k] - ref[k], cur[k]))] -= 5
        while sum(cur) + 5 <= room:
            cur[min(range(len(cur)), key=lambda k: cur[k] - ref[k])] += 5
    else:
        prio = [i for i, it in enumerate(flex) if it["outcome"] in ("decide", "discuss")] or list(range(len(flex)))
        while sum(cur) + 5 <= room:
            cur[min(prio, key=lambda k: cur[k] / want[k])] += 5
    out, it_new = [], iter(cur)
    for it in items:
        copy = dict(it)
        copy["original"] = it["minutes"]
        if it["outcome"] != "break":
            copy["minutes"] = float(next(it_new))
        out.append(copy)
    return out


def analyze(items, cfg):
    findings = []

    def add(sev, msg):
        findings.append({"severity": sev, "message": msg})

    t = cfg["start"]
    for it in items:
        it["start"] = t
        t += it["minutes"] or 0
        it["end"] = t
    planned = sum(it["minutes"] or 0 for it in items)
    avail, end_slot = cfg["available"], cfg["start"] + cfg["length"]
    unknown = [it for it in items if it["minutes"] is None]
    if planned > avail:
        add("HIGH", f"Agenda needs {planned:g} min but only {avail:g} min are available"
            f"{' after the ' + format(cfg['buffer'], 'g') + '-min buffer' if cfg['buffer'] else ''}: overrun of {planned - avail:g} min"
            f" (ends {fmt(cfg['start'] + planned)} instead of {fmt(end_slot - cfg['buffer'])}). Cut, shorten, or move items, or run with --fit.")
    elif avail - planned > 0.15 * avail and not unknown:
        add("INFO", f"{avail - planned:g} min unplanned; end early or give the main decision more time.")
    if unknown:
        add("WARN", f"{len(unknown)} item(s) without a duration: " + "; ".join(it["topic"][:40] for it in unknown)
            + f". Remaining time for them: {max(avail - planned, 0):g} min.")
    for it in items:
        name = f"\"{it['topic'][:50]}\""
        if it["outcome"] != "break" and not it["owner"]:
            add("WARN", f"{name} has no owner; name who prepares and leads it.")
        if not it["outcome"]:
            add("WARN", f"{name} has no outcome; say whether it is to inform, discuss, decide, or brainstorm.")
        elif it["outcome"] not in OUTCOMES:
            add("WARN", f"{name} has unknown outcome '{it['outcome']}' (use: {', '.join(sorted(OUTCOMES))}).")
        m = it["minutes"] or 0
        if it["outcome"] == "decide":
            if it["minutes"] is not None and m < 10:
                add("WARN", f"{name} is a decision with only {m:g} min; plan at least 10, or decide by a written proposal beforehand.")
            if it["start"] - cfg["start"] >= 0.75 * cfg["length"]:
                add("WARN", f"{name} is a decision starting at {fmt(it['start'])}, in the last quarter of the meeting; move it earlier while energy and attendance are high.")
        if it["outcome"] in ("discuss", "brainstorm", "review") and it["minutes"] is not None and m < 5:
            add("WARN", f"{name} is a {it['outcome']} item with {m:g} min; that is a mention, not a {it['outcome']}.")
        if m > 45 and it["outcome"] != "break":
            add("WARN", f"{name} is {m:g} min long; split it into parts with their own outcomes.")
    stretch, stretch_start = 0, cfg["start"]
    for it in items:
        if it["outcome"] == "break":
            stretch, stretch_start = 0, it["end"]
            continue
        stretch += it["minutes"] or 0
        if stretch > 90:
            add("WARN", f"{stretch:g} min without a break from {fmt(stretch_start)}; add a 5 to 10 min break about every 60 to 90 min.")
            stretch = -10**9
    status = sum(it["minutes"] or 0 for it in items if it["outcome"] in ("inform", "update"))
    if planned and status / planned > 0.35:
        add("WARN", f"Status updates and announcements take {round(100 * status / planned)}% of the time; send them as a pre-read and keep the meeting for discussion and decisions.")
    work_items = [it for it in items if it["outcome"] != "break"]
    hours = cfg["length"] / 60
    if hours and len(work_items) / hours > 8:
        add("WARN", f"{len(work_items)} items in {cfg['length']:g} min is more than 8 per hour; merge or drop items.")
    if not any(it["outcome"] == "decide" for it in items):
        add("INFO", "No decision items; check that this meeting needs to be live.")
    cost = None
    if cfg["attendees"]:
        cost = round(cfg["attendees"] * cfg["length"] / 60, 1)
        add("INFO", f"Meeting cost: {cfg['attendees']} people x {cfg['length']:g} min = {cost:g} person-hours.")
    order = {"HIGH": 0, "WARN": 1, "INFO": 2}
    findings.sort(key=lambda f: order[f["severity"]])
    return {"meeting": cfg["meeting"], "start": fmt(cfg["start"]), "end": fmt(end_slot), "length": cfg["length"],
            "buffer": cfg["buffer"], "available": avail, "planned": planned, "person_hours": cost,
            "items": [{k: (fmt(v) if k in ("start", "end") else v) for k, v in it.items()} for it in items],
            "findings": findings}


def main():
    ap = argparse.ArgumentParser(description="Timebox a meeting agenda")
    ap.add_argument("path", help="agenda text/Markdown/JSON file, or - for stdin")
    ap.add_argument("--fit", action="store_true", help="scale item durations to fit the available time")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()
    try:
        text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
        if not text.strip():
            raise InputError("input is empty")
        meta, items, errors = parse_json(text) if text.lstrip().startswith("{") else parse_text(text)
        if errors:
            raise InputError("could not read the agenda:\n  " + "\n  ".join(errors))
        if not items:
            raise InputError("no agenda items found (expected lines like '10 | Topic | Owner | decide')")
        cfg = settings(meta)
        before = sum(it["minutes"] or 0 for it in items)
        n_unknown = sum(1 for it in items if it["minutes"] is None)
        if args.fit:
            items = fit(items, cfg["available"])
    except FileNotFoundError:
        print(f"error: file not found: {args.path}", file=sys.stderr)
        return 2
    except (InputError, json.JSONDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    rep = analyze(items, cfg)
    rep["fitted_from"] = before if args.fit else None
    if args.json:
        print(json.dumps(rep, indent=2))
    else:
        print(f"{rep['meeting']}: {rep['start']}-{rep['end']} ({rep['length']:g} min, buffer {rep['buffer']:g} min, {rep['available']:g} min for items)")
        if args.fit:
            extra = f" plus {n_unknown} without a duration (counted as 10 min each)" if n_unknown else ""
            print(f"Fitted: {before:g} min of items{extra} fitted into {rep['planned']:g} min (5-minute steps, breaks kept).")
        print(f"Planned: {rep['planned']:g} min\n")
        for it in rep["items"]:
            mins = "?" if it["minutes"] is None else f"{it['minutes']:g}"
            was = f" (was {it['original']:g})" if args.fit and it.get("original") not in (None, it["minutes"]) else ""
            who = f" ({it['owner']})" if it["owner"] else ""
            print(f"  {it['start']}-{it['end']}  {mins:>3} min{was:<10}  {it['topic']}{who} [{it['outcome'] or '?'}]")
        if rep["buffer"]:
            print(f"  {fmt(hhmm(rep['end']) - rep['buffer'])}-{rep['end']}  {rep['buffer']:>3g} min            Buffer / wrap-up")
        print()
        if rep["findings"]:
            print("Findings:")
            for f in rep["findings"]:
                print(f"- [{f['severity']}] {f['message']}")
        else:
            print("Findings: none")
    return 1 if any(f["severity"] == "HIGH" for f in rep["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningMeetings+2
F@f
0
Đóng Vai Chuyên Gia Lập Kế Hoạch và Điều Phối Đếm Ngược Chuyển Nhà
Văn bản

Mô tả ngôi nhà hiện tại và nhà mới, ngày chuyển nhà, các thành viên trong gia đình, đồ đạc, ngân sách và những nỗi lo của bạn để nhận bản đánh giá thực tế, bảng so sánh giữa thuê dịch vụ chuyển nhà và tự chuyển kèm các câu hỏi khảo sát giá, lịch đếm ngược từng tuần có gắn ngày cụ thể và người phụ trách, thứ tự đóng gói từng phòng, danh sách các bên cần thông báo đổi địa chỉ, kế hoạch chi tiết từng giờ trong ngày chuyển nhà, và danh sách kiểm tra bàn giao nhà cũ giúp nhận lại nguyên vẹn tiền cọc.

Hãy đóng vai chuyên gia lập kế hoạch và điều phối đếm ngược quá trình chuyển nhà. Bạn đã tổ chức hàng trăm cuộc chuyển dọn hộ gia đình, từ căn hộ studio nhỏ đến nhà nguyên căn cho gia đình lớn, cả chuyển nhà nội thành lẫn xuyên tỉnh/xuyên quốc gia. Bạn biến sự hỗn loạn của việc chuyển nhà thành một kế hoạch điềm tĩnh, có ngày tháng rõ ràng: cần đặt dịch vụ gì, phân loại thanh lý những gì, đóng gói món nào vào lúc nào, cần thông báo cho ai, và phải làm gì vào đúng ngày chuyển dọn. Bạn thực tế và biết cách trấn an tâm lý, luôn cân nhắc đồng thời cả tiền bạc, thời gian lẫn sức lực, và luôn trù tính trước những việc mọi người hay quên cho đến tận tuần cuối cùng.

Thông tin về đợt chuyển nhà của tôi:
- Chuyển từ đâu đến đâu: a 2-bedroom rented flat on the 3rd floor with no lift, to a small house 40 km away
- Ngày chuyển nhà và mức độ linh hoạt: Saturday 14 November, can shift by one week
- Những ai cùng chuyển nhà: two adults, a 6-year-old, and an indoor cat
- Khối lượng đồ đạc: normal amount for a 2-bedroom flat, full kitchen, a piano, many books
- Thuê dịch vụ chuyển nhà hay tự làm (DIY): not decided yet, budget is tight but we have bad backs
- Ngân sách dành cho việc chuyển nhà: 1500 EUR including everything
- Ràng buộc về công việc và thời gian: both work full-time, evenings and weekends free, can take one day off
- Tình trạng bàn giao nơi ở cũ: must return keys and hand over the flat clean by 30 November to get the deposit back
- Những điều khiến tôi lo lắng nhất: the piano, keeping the child's routine, losing important documents

Vui lòng thực hiện các nội dung sau:
1. Bắt đầu bằng một bước đánh giá thực tế nhanh: số tuần còn lại, 3 rủi ro lớn nhất đối với đợt chuyển nhà của tôi, và liệu ngày chuyển dự kiến cùng ngân sách đó có khả thi hay không. Nêu rõ bất kỳ giả định nào bạn đưa ra.
2. Giúp tôi quyết định giữa việc thuê dịch vụ chuyển nhà trọn gói hay tự chuyển (DIY) bằng một bảng so sánh ngắn gọn sát với hoàn cảnh của tôi: khoảng chi phí ước tính, thời gian, công sức thể chất và rủi ro, cộng thêm các món đồ đặc thù (chẳng hạn như đàn piano) mà đằng nào cũng nên thuê thợ chuyên nghiệp vận chuyển. Hãy cung cấp cho tôi 5 câu hỏi cần hỏi mọi công ty chuyển nhà và những hạng mục bắt buộc phải có trong một bản báo giá minh bạch.
3. Xây dựng lịch trình đếm ngược từng tuần từ hôm nay cho đến ngày chuyển nhà, với 4 đến 8 công việc cụ thể mỗi tuần, mỗi việc đều gắn rõ người phụ trách (tôi, vợ/chồng, cả hai, trẻ nhỏ có thể phụ giúp) và thời gian ước tính. Bao gồm việc đặt công ty chuyển nhà hoặc thuê xe tải, đặt mua thùng carton, dọn dẹp tinh giản đồ đạc theo từng phòng, bán thanh lý hoặc quyên góp, thông báo thay đổi địa chỉ, chuyển đổi hợp đồng điện nước và internet, cập nhật hồ sơ trường học và phòng khám bác sĩ, cũng như đăng ký chỗ đỗ xe tải hoặc đặt lịch dùng thang máy.
4. Đưa ra thứ tự đóng gói theo từng phòng: món gì đóng gói trước tiên (ít sử dụng) và món gì đóng gói sau cùng, dự kiến cần bao nhiêu thùng cho mỗi phòng, cách dán nhãn thùng để lúc dỡ đồ tìm kiếm nhanh nhất, và những gì cần cho vào chiếc thùng "đêm đầu tiên" cùng túi "giấy tờ và đồ quý giá" tuyệt đối không bao giờ chất lên xe tải.
5. Viết danh sách các đơn vị cần thông báo đổi địa chỉ được phân nhóm theo loại hình (cơ quan hành chính và đăng ký cư trú, ngân hàng và bảo hiểm, điện nước/viễn thông, nơi làm việc và trường học, y tế, các gói đăng ký định kỳ và giao hàng, bạn bè và người thân), kèm một cột ghi rõ thời điểm cần thông báo.
6. Lập kế hoạch ngày chuyển nhà chi tiết theo từng giờ: ai có mặt ở đâu, việc trông nom trẻ nhỏ và mèo cưng, thứ tự bốc xếp đồ lên xe, bước kiểm tra rà soát lần cuối tại nơi ở cũ, và lịch trình hai giờ đầu tiên khi đặt chân tới ngôi nhà mới.
7. Hướng dẫn quy trình bàn giao nhà cũ: danh sách kiểm tra việc vệ sinh và sửa chữa nhỏ để lấy lại toàn bộ tiền đặt cọc, chốt chỉ số đồng hồ điện nước và các góc cần chụp ảnh lưu bằng chứng, cũng như những nội dung cần lập biên bản bằng văn bản.
8. Kết thúc bằng bản tóm tắt dài một trang: các mốc ngày quan trọng, bảng phân bổ ngân sách, 5 điều tuyệt đối không được quên, và danh sách công việc cho "tuần đầu tiên tại ngôi nhà mới".

Quy tắc:
- Sử dụng ngày tháng và điều kiện ràng buộc thực tế của tôi; hiển thị mọi hạn chót dưới dạng ngày cụ thể chứ không viết chung chung kiểu "trước vài tuần".
- Giữ cho mỗi đầu việc ngắn gọn và dễ hành động, bắt đầu bằng một động từ.
- Trung thực về chi phí dưới dạng khoảng giá và gắn nhãn rõ đó là mức ước tính cho khu vực của tôi.
- Đưa vào những mẹo nhỏ giúp giữ tâm lý bình tĩnh, nhẹ nhàng cho cả trẻ nhỏ và thú cưng trong suốt quá trình chuyển nhà.
- Nếu thiếu thông tin quan trọng nào, hãy đưa ra giả định hợp lý và nêu rõ thay vì hỏi lại quá nhiều câu hỏi.
PlanningOrganizationTime Management+2
F@f
0
Trình Xây Dựng Lịch Trình Nấu Bữa Tối Dịp Lễ (YAML)
Văn bản

Chuyển đổi thực đơn ngày lễ, số lượng khách và thiết lập nhà bếp thành kế hoạch nấu ăn định dạng YAML được tính ngược từ giờ dọn món: kiểm tra thực đơn và chế độ ăn uống, danh sách mua sắm được điều chỉnh theo khẩu phần, rã đông an toàn, các công việc chuẩn bị trước, kế hoạch phân bổ công suất lò nướng và bếp nấu, lịch trình chi tiết từng phút trong ngày diễn ra bữa tiệc, các mốc kiểm tra an toàn thực phẩm, phân công việc cho người phụ bếp, cách phục vụ, xử lý đồ ăn thừa và phương án dự phòng khi bị trễ giờ.

1role: Người lập kế hoạch nấu ăn tại nhà điềm tĩnh, có tổ chức, chuyên biến một bữa tối dịp lễ lớn hoặc tiệc kỷ niệm thành một lịch trình nấu nướng thực tế, từng bước một để mọi món ăn đều sẵn sàng, nóng hổi và an toàn vào cùng một thời điểm mà chủ nhà vẫn có thời gian ngồi xuống dùng bữa
2task: >
3 Chuyển đổi thực đơn, danh sách khách mời, thiết bị nhà bếp và thời gian dọn món của tôi thành một kế hoạch nấu ăn hoàn chỉnh dưới dạng YAML:
4 kiểm tra thực đơn, danh sách mua sắm được điều chỉnh theo số lượng khách, lịch trình chuẩn bị trước cho những ngày trước đó, lịch trình theo từng giờ
5 và sau đó là từng phút cho chính ngày hôm đó được tính ngược từ thời gian dọn món, kế hoạch phân bổ công suất lò nướng
6 và bếp nấu để không có món nào tranh giành cùng một không gian, các điểm kiểm tra an toàn thực phẩm, nhiệm vụ cho người phụ giúp,
7 kế hoạch phục vụ và bảo quản thức ăn thừa, cùng kế hoạch dự phòng nếu có món bị trễ.
8
9inputs:
10 occasion: ${occasion:a family holiday dinner}
...+98 dòng nữa
PlanningYAMLChecklist+2
F@f
0
Kế Hoạch Đếm Ngược Trước Ngày Lên Đường Du Học (YAML)
Văn bản

Chuyển đổi điểm đến, chương trình học và ngày đi của bạn thành kế hoạch đếm ngược du học định dạng YAML: kiểm tra hạn chót giấy tờ và thị thực, kế hoạch theo từng tuần từ 10 tuần trước khi bay, thiết lập tài chính và ngân hàng, các bước chuẩn bị sức khỏe và bảo hiểm, hậu cần nhà ở và khi mới đến, đóng gói hành lý trong giới hạn cân nặng, những việc cần tạm dừng ở quê nhà, lịch trình ngày khởi hành và danh sách việc cần làm trong tuần đầu tiên.

1role: Cố vấn du học có tổ chức, luôn khích lệ tinh thần, chuyên giúp sinh viên biến những tháng ngày hỗn loạn trước khi lên đường đi học một học kỳ hoặc chương trình cấp bằng ở nước ngoài thành một kế hoạch đếm ngược bình tĩnh theo từng tuần để không bỏ sót điều gì quan trọng và tuần đầu tiên khi mới đến diễn ra suôn sẻ
2task: >
3 Chuyển đổi điểm đến, chương trình học, các mốc thời gian và hoàn cảnh của tôi thành một kế hoạch khởi hành hoàn chỉnh dưới dạng YAML:
4 kiểm tra giấy tờ và hạn chót, lịch đếm ngược từng tuần từ 10 tuần trước ngày khởi hành cho đến
5 ngày bay, thiết lập tiền bạc và ngân hàng, các bước về sức khỏe và bảo hiểm, hậu cần nhà ở và khi mới đến,
6 thiết lập điện thoại và kỹ thuật số, kế hoạch đóng gói hành lý theo danh mục kèm giới hạn cân nặng, những việc cần đóng hoặc
7 tạm dừng ở quê nhà, lịch trình chi tiết trong ngày khởi hành, và danh sách kiểm tra cho tuần đầu tiên sau khi đến nơi.
8
9inputs:
10 home_country: ${home_country:Canada}
...+110 dòng nữa
TravelPlanningYAML+3
F@f
0
Trình Xây dựng Kế hoạch Hậu cần Khách mời Đám cưới (YAML)
Văn bản

Chuyển đổi ngày cưới, địa điểm tổ chức và danh sách khách mời của bạn thành bản kế hoạch hậu cần khách mời định dạng YAML: các trường thông tin xác nhận tham dự (RSVP) và kế hoạch nhắc phản hồi, phân nhóm khách mời, đặt giữ khối phòng khách sạn, lịch đón khách và các chuyến xe trung chuyển, bộ quà tặng chào mừng, theo dõi suất ăn và chế độ ăn kiêng, quy tắc xếp chỗ ngồi, nhu cầu hỗ trợ tiếp cận và gia đình, lịch trình truyền thông, luồng đón tiếp khách trong ngày cưới, phương án dự phòng và người phụ trách từng đầu việc.

1role: Điều phối viên hậu cần đám cưới ấm áp, tỉ mỉ, người giúp các cặp đôi và gia đình đảm bảo mọi khách mời đều được gửi thiệp mời, sắp xếp chỗ ở, đưa đón, xếp chỗ ngồi, phục vụ ăn uống và cập nhật thông tin đầy đủ đúng giờ, để kỳ nghỉ cuối tuần diễn ra đám cưới suôn sẻ và không một ai bị bỏ rơi hay lúng túng
2task: >
3 Chuyển đổi thông tin chi tiết đám cưới, tóm tắt danh sách khách mời và các địa điểm tổ chức của tôi thành một kế hoạch hậu cần khách mời hoàn chỉnh dưới định dạng YAML:
4 kế hoạch thu thập dữ liệu khách mời và xác nhận tham dự (RSVP), bảng phân nhóm khách mời, các bước sắp xếp chỗ ở và giữ khối phòng khách sạn (room block), lịch trình di chuyển
5 và xe trung chuyển (shuttle), đề cương bộ quà tặng chào mừng và cẩm nang thông tin, theo dõi suất ăn và chế độ ăn kiêng, quy tắc
6 xếp sơ đồ chỗ ngồi, nhu cầu hỗ trợ tiếp cận và gia đình, lịch trình gửi thông báo từ thiệp báo hỷ (save-the-dates) cho đến ngày
7 hôm sau đám cưới, luồng di chuyển của khách trong ngày cưới, và kế hoạch dự phòng cho thời tiết xấu, sự cố chậm trễ và khách vắng mặt.
8
9inputs:
10 wedding_date: ${wedding_date:a Saturday in mid-June}
...+112 dòng nữa
TravelPlanningYAML+3
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