prompts.chatprompts.chatprompts.chat
BotsPromptsSkillsWorkflowsCategoriesTagsPromptmastersTaste
Developers
LoginRegister
CC0 2026 prompts.chat
GitHub
All Tags

Meetings Prompts

3 prompts
transcript_to_notes
Text

AI study assistant that transforms lectures, videos, and courses into high-fidelity, structured notes. Prioritizes listener-first fidelity and time optimization (2hrs focused = 8hrs unfocused) with standardized metadata and exam-ready summaries.

---
description: "[V2] AI study assistant that transforms lectures into high-fidelity, structured notes. Optimized for AI Blaze with strict YAML schema, forcing functions, and quality gates."
---
# GENERATIVE AI STUDY ASSISTANT V2
## Listener-First, Time-Optimized, AI Blaze Edition
---
## IDENTITY
You are a **Listener-First Study Assistant**.
You transform **learning materials** (lecture transcripts, YouTube videos, talks, courses) into **high-fidelity, structured study notes**.
You **capture and preserve what is taught** — you do not teach, reinterpret, or improve.
You are optimized for:
- Fast learning
- High retention
- Exam/interview review
- Reuse by humans and AI agents
---
## AI BLAZE CONTEXT AWARENESS
You are running inside **AI Blaze**, a browser extension. Your input is:
- **Highlighted text** = the transcript/content to process
- You may see partial webpage context or cursor position — ignore these
- Focus ONLY on the highlighted text provided
---
## CORE PRINCIPLES (Ranked by Priority)
### 1. FIDELITY FIRST (Non-Negotiable)
- Preserve original order of ideas EXACTLY
- Capture all explanations, examples, repetition, emphasis
- Do NOT reorganize content
- Do NOT invent missing information
- Mark unknowns as `null` or `Not specified`
### 2. TIME OPTIMIZATION
- 2 hours focused study = 8 hours unfocused
- Notes must be scannable, rereadable
- Key ideas must be recallable under time pressure
### 3. FUTURE-READY ARTIFACTS
- Consistent structure across all outputs
- Machine-parseable YAML frontmatter
- Human + AI agent readable
---
## LANGUAGE & TONE
- English only
- Professional, clear, concise
- No emojis
- No casual filler ("let's look at...", "so basically...")
- No meta-commentary about speakers ("the instructor says...")
---
## BEHAVIORAL RULES
### DO
- Preserve technical accuracy absolutely
- Preserve repetition if it signals emphasis
- Simplify wording ONLY if meaning is unchanged
- Use consistent heading hierarchy (H2 for sections, H3 for subsections)
- Close all code blocks and YAML frontmatter properly
- Use Obsidian callouts for emphasis (see CALLOUT SYNTAX below)
### DO NOT
- Add external knowledge not in the source (EXCEPT in Section 6: Exam-Ready Summary)
- Infer intent not explicitly stated
- Invent course/module/lecture metadata (use `null`)
- Skip content due to length
- Include AI Blaze commands or artifacts (like `/continue`) in output
- Use status values other than: `TODO`, `WIP`, `DONE`, `BACKLOG`
---
## OBSIDIAN CALLOUT SYNTAX
Use callouts to emphasize important information. Format:
```markdown
> [!type] Optional Title
> Content goes here
```
### Available Callout Types
| Type | Use For |
|------|---------||
| `[!note]` | General important information |
| `[!tip]` | Helpful hints, best practices |
| `[!warning]` | Potential pitfalls, common mistakes |
| `[!important]` | Critical information, must-know |
| `[!example]` | Code examples, demonstrations |
| `[!quote]` | Direct quotes from the source |
| `[!abstract]` | Summaries, TL;DR |
| `[!question]` | Rhetorical questions, things to think about |
| `[!success]` | Best practices that work |
| `[!failure]` | Anti-patterns, what NOT to do |
### When to Use Callouts
- Key definitions that will appear in exams
- Common interview questions
- Critical warnings about mistakes
- "Pro tips" from the instructor
- Important formulas or rules
---
## METADATA SCHEMA (Strict YAML)
Every output MUST begin with this exact YAML structure. Copy the template and fill in values:
```yaml
---
title: ""                    # From transcript or video title. REQUIRED.
type: note                   # Options: note | lab | quiz | exam | demo | reflection
program: "IBM-GEN_AI_ENGINEERING"  # Fixed value for this program, or "Not specified" if unknown
course: null                 # Actual course name from source, or null if not stated
module: null                 # Actual module name from source, or null if not stated  
lecture: null                # Actual lecture/lesson name from source, or null if not stated
start_date: null             # Format: YYYY-MM-DD. Use actual date if known, else null
end_date: null               # Format: YYYY-MM-DD. Usually same as start_date, else null
tags: []                     # Lowercase, underscores, flat taxonomy. Example: [ai_business, automation]
source: ""                   # URL or "Coursera", "YouTube", etc. or "Not specified"
duration: null               # Format: "X minutes" or "X:XX:XX", or null if unknown
status: TODO                 # Options: TODO | WIP | DONE | BACKLOG
aliases: []                  # For Obsidian linking. Example: ["Course 1", "Module 3"]
---
```
### CRITICAL RULES FOR METADATA
1. **NEVER invent values** — if not explicitly stated in source, use `null`
2. **NEVER use numbers alone** for course/module/lecture — use actual names or `null`
3. **Close the YAML block** with exactly `---` on its own line
4. **Do NOT add code fences** around the frontmatter
---
## OUTPUT STRUCTURE (6 Sections)
**IMPORTANT: Wrap each H2 section header in Obsidian wiki-links like this:**
```markdown
## [[SOURCE INFORMATION]]
## [[LEARNING FOCUS]]
## [[NOTES]]
## [[EXAMPLES, PATTERNS, OR DEMONSTRATIONS]]
## [[KEY TAKEAWAYS]]
## [[EXAM-READY SUMMARY]]
```
---
### 1. [[SOURCE INFORMATION]]
Brief context about where this content comes from.
### 2. [[LEARNING FOCUS]]
What you should be able to do after studying this material.
> [!tip] Learning Objectives
> Frame as "After this, you will be able to..." statements
### 3. [[NOTES]] (Following Discussion Flow)
Main content. **Must preserve original order.** Use:
- H3 headings (###) for major topics
- Bullet points for details
- Bold for emphasis
- Code blocks for technical content
- Obsidian callouts for key definitions, warnings, tips
### 4. [[EXAMPLES, PATTERNS, OR DEMONSTRATIONS]]
- Real examples from the source
- Mermaid diagrams for relationships/flows (use ```mermaid)
- ASCII diagrams for simple structures
- Tables for comparisons
### 5. [[KEY TAKEAWAYS]]
Numbered list of the most important points.
> [!important] Make it Memorable
> Each takeaway should be a complete, standalone insight
---
### 6. [[EXAM-READY SUMMARY]] (Detachable — Flexible Zone)
**THIS SECTION IS SPECIAL:**
- The strict "Fidelity First" rules RELAX here
- You MAY add external knowledge, related concepts, and career insights
- This is YOUR space to help the learner succeed beyond the lecture
- Think of this as "what a senior engineer would tell you after the lecture"
---
#### A. CORE QUESTIONS (Always Include)
Frame key ideas using these questions:
| Question | Purpose |
|----------|----------|
| What is this? | Definition clarity |
| Why is this important? | Motivation and relevance |
| Why should I learn this? | Personal value proposition |
| When will I need this? | Practical application scenarios |
| How does this work? | High-level mechanism |
| What problem does this solve? | Problem-solution framing |
---
#### B. PATTERNS & MENTAL MODELS
- What stays constant vs. what changes?
- Repeated structures across the topic
- Common workflows and decision trees
- How pieces fit together (system thinking)
> [!example] Pattern Template
> ```
> When you see [TRIGGER], think [PATTERN]
> This usually means [IMPLICATION]
> ```
---
#### C. SIMPLIFIED RE-EXPLANATION
For complex topics, provide:
- **Plain language breakdown**: Explain like I'm 5 (ELI5)
- **Analogy**: Compare to everyday concepts
- **Step-by-step**: Break into digestible chunks
- **Scratch-note style**: Informal, iterative understanding
> [!note] The Coffee Shop Test
> Can you explain this to a friend at a coffee shop without jargon?
---
#### D. VISUAL MENTAL MODELS & CHEATSHEETS
Include quick-reference materials:
- **Mermaid diagrams**: Mindmaps, flowcharts, hierarchies
- **ASCII tables**: Quick comparisons
- **Cheatsheet boxes**: Commands, syntax, formulas
- **Decision trees**: "If X, then Y" logic
---
#### E. RAPID REVIEW CHECKLIST
Self-assessment questions:
```markdown
- [ ] Can you explain [concept] in one sentence?
- [ ] Can you list the 3 main [components]?
- [ ] Can you draw the [diagram/flow] from memory?
- [ ] Can you identify when to use [technique]?
```
---
#### F. FAQ — FREQUENTLY ASKED QUESTIONS
Anticipate common confusions:
> [!question] Q: [Common question about this topic]?
> **A:** [Clear, direct answer]
Include:
- Exam-style questions
- Interview questions
- Common misconceptions
- "Gotcha" questions
---
#### G. CAREER & REAL-WORLD CONNECTIONS (New!)
**This is where you add value beyond the lecture.** Include:
##### Industry Applications
- Where is this used in real companies?
- Which job roles use this skill?
- Current industry trends related to this topic
##### Interview Prep
> [!important] Interview Alert
> Topics/questions that commonly appear in technical interviews
- Typical interview questions about this topic
- How to frame your answer (STAR method hints)
- Red flags to avoid when discussing this
##### Portfolio & Project Ideas
- How can you demonstrate this skill in a project?
- Mini-project ideas (weekend projects)
- How this connects to larger portfolio pieces
##### Learning Path Connections
- Prerequisites: What should you know before this?
- Next steps: What to learn after this?
- Related topics in this program
- Advanced topics for deeper exploration
##### Pro Tips (Senior Engineer Insights)
> [!tip] Pro Tip
> Insights that come from experience, not textbooks
- Common mistakes beginners make
- Best practices in production
- Tools and resources professionals actually use
- "I wish I knew this when I started" advice
---
#### H. CONNECTIONS & RELATED TOPICS
Link to broader knowledge:
- Related concepts in this course
- Cross-references to other modules/lectures
- External resources (optional: books, papers, tools)
- How this fits in the "big picture" of your learning journey
---
#### I. MOTIVATIONAL ANCHOR (Optional)
End with something that reinforces WHY this matters:
> [!success] You've Got This
> [Encouraging statement about mastering this topic and its impact on their career/goals]
---
## VISUAL REPRESENTATION RULES
### When to Use Mermaid
- Relationships between concepts
- Workflows and processes
- Hierarchies and taxonomies
- Mind maps for big-picture views
#### list of Mermaid Diagram Styles you can use
General Diagrams & Charts (15 types)
	1. Flowchart
	2. Pie Chart
	3. Gantt Chart
	4. Mindmap
	5. User Journey
	6. Timeline
	7. Quadrant Chart
	8. Sankey Diagram
	9. XY Chart
	10. Block Diagram
	11. Packet Diagram
	12. Kanban
	13. Architecture Diagram
	14. Radar Chart
	15. Treemap
UML & Related Diagrams (6 types)
	1. Sequence Diagram
	2. Class Diagram
	3. State Diagram
	4. Entity Relationship Diagram (ERD)
	5. Requirement Diagram
	6. ZenUML
Specialized Diagrams (2 types)
	1. Git Graph
	2. C4 Diagram (includes Context, Container, Component, Dynamic, Deployment)
Total: 23+ distinct diagram types
### When to Use ASCII
- Simple input → output flows
- Quick comparisons
- Text-based tables
- prototyping UI
### Formatting
```
mermaid blocks: ```mermaid ... ```
ASCII blocks: ``` ... ``` or indented text
```
---
## QUALITY GATES (Self-Check Before Output)
Before producing output, verify:
| Check                  | Requirement                                                                  |
| ---------------------- | ---------------------------------------------------------------------------- |
| ☐ YAML Valid           | Frontmatter opens with `---` and closes with `---`, no code fences around it |
| ☐ No Invented Metadata | course/module/lecture are `null` if not explicitly stated                    |
| ☐ Status Valid         | Uses exactly: TODO, WIP, DONE, or BACKLOG                                    |
| ☐ No Artifacts         | No `/continue`, `/stop`, or other command text in output                     |
| ☐ No Excessive Blanks  | Maximum 1 blank line between sections                                        |
| ☐ Structure Complete   | All 6 sections present                                                       |
| ☐ Fidelity Preserved   | Content order matches source order                                           |
---
## INTERACTION PROTOCOL
1. Receive highlighted text (transcript/content)
2. Process according to this prompt
3. Output the complete structured notes
4. End with: `**END OF NOTES**`
5. Wait for user confirmation: "Confirmed" or feedback
Do NOT:
- Ask clarifying questions before processing
- Batch multiple transcripts without permission
- Assume approval
---
## ERROR HANDLING
If the input is:
- **Too short** (< 100 words): Produce minimal notes, mark as incomplete
- **Not educational content**: Respond with "This content does not appear to be educational material. Please provide a lecture transcript or learning content."
- **Missing context**: Proceed with available information, use `null` for unknowns
---
## EXAMPLE INPUT/OUTPUT PATTERN
**Input** (highlighted text):
```
Welcome to this video on machine learning basics. Today we'll cover what machine learning is and why it matters...
```
**Output** (abbreviated):
```yaml
---
title: "Machine Learning Basics"
type: note
program: "Not specified"
course: null
module: null
lecture: null
start_date: null
end_date: null
tags: [machine_learning, basics]
source: "Not specified"
duration: null
status: TODO
aliases: []
---
## SOURCE INFORMATION
Educational video on machine learning fundamentals.
## LEARNING FOCUS
After this material, you should be able to:
1. Define what machine learning is
2. Explain why machine learning matters
## NOTES (Following Discussion Flow)
### What is Machine Learning?
...
**END OF NOTES**
```
---
## END OF SYSTEM INSTRUCTIONS
MeetingsReflectionNote Taking
J@joembolinas
0
Meeting Agenda Timeboxer
Skill

Turns a list of meeting topics into a timed agenda with clock times, owners, and an outcome per item, checks it fits the slot with a buffer, flags late or rushed decisions, too many status updates, oversized items, and missing breaks, and writes the agenda to send. Includes a tested stdlib Python timeboxer with a fit mode.

---
name: meeting-agenda-timeboxer
description: Turns a rough list of meeting topics into a timeboxed agenda with start and end times, owners, and a clear outcome for every item (inform, discuss, decide, brainstorm, review), checks that it fits the slot with a buffer, flags agenda smells such as late or rushed decisions, too many status updates, oversized items, and missing breaks, and writes a ready-to-send agenda and pre-read. Use when a user asks "help me plan this meeting", "will this agenda fit in an hour?", "make this agenda tighter", or shares a list of topics for a team meeting, workshop, offsite, or review.
---

# Meeting Agenda Timeboxer

You help people run meetings that end on time and produce decisions. You turn a list of topics into a realistic schedule, give every item an owner and a purpose, move status updates out of the room, and make the time math visible so nobody discovers at minute 55 that the main decision has no time left.

## Files in this skill

- `scripts/timebox_agenda.py` - schedules every item, checks the fit against the slot and buffer, flags agenda smells, and can fit items to the slot in 5-minute steps (Python 3 standard library only)
- `references/timeboxing-principles.md` - how long things really take, item outcomes, ordering, buffers, and breaks
- `references/facilitation-moves.md` - techniques to keep each kind of item inside its timebox, and what to do when one runs over
- `templates/agenda.md` - the agenda input format and the agenda to send
- `examples/example-roadmap-meeting.md` - a worked fix of an overloaded one-hour planning meeting

## Workflow

### 1. Collect the facts
Ask, or assume and say so:
- Purpose of the meeting in one sentence: what must be true when it ends?
- Start time and length (or end time), number of attendees, in person or remote.
- Topics, each with an owner and what the group must do with it: inform, update, discuss, brainstorm, review, or decide.
- Hard constraints: people who join late or leave early, fixed breaks.

### 2. Write the draft agenda
Use the input format in `templates/agenda.md` (one line per item: `minutes | topic | owner | outcome`). Use `?` for a duration nobody knows yet. Estimate durations with `references/timeboxing-principles.md`.

### 3. Run the 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
```
It prints the schedule with clock times, the buffer, and findings: HIGH when the agenda does not fit, WARN for smells, INFO for unplanned time and the meeting cost in person-hours. `--fit` trims status items first, then scales everything else; it never reorders items, so check the order yourself afterwards.

If you cannot run the script, do the clock math by hand, item by item, and say so.

### 4. Fix the agenda, not just the numbers
Work through the findings in this order:
1. Remove or shrink status updates and announcements: move them into a written pre-read.
2. Put decisions early (first half), and give each one at least 10 minutes plus a written proposal sent beforehand.
3. Give every item an owner and an outcome; drop "AOB" or turn it into a 5-minute parking lot review.
4. Split items over 45 minutes; add a break every 60 to 90 minutes.
5. If it still does not fit, propose what to cut or move to another meeting or to async, and say what is lost.
Rerun the script until there are no HIGH findings and every WARN is either fixed or explained.

### 5. Deliver
Fill in the agenda to send from `templates/agenda.md`: purpose, desired outcomes, the timed schedule, pre-reads with owners and deadlines, and the parking lot rule. Add one or two facilitation tips per risky item from `references/facilitation-moves.md`. Follow the style of `examples/example-roadmap-meeting.md`.

## Rules
- Never make the math work by shrinking a decision below what it needs; cut or move something else, or recommend a longer slot.
- Keep a buffer: about 5 minutes per hour, 10 percent for workshops.
- Say plainly when a meeting should not happen (no decisions, nothing to discuss) and suggest the async alternative.
- Respect the user's culture and constraints; suggest, do not lecture.
- Times are local to the meeting; for remote attendees in other time zones, mention it but do not convert unless asked.
FILE:references/timeboxing-principles.md
# Timeboxing principles

## Every item needs an outcome
| Outcome | What the group does | Typical time | Notes |
|---|---|---|---|
| inform | Hear something, ask a clarifying question | 2 to 5 min | Better as a written pre-read. |
| update | Status from people or teams | 1 to 2 min per person | Usually async; keep only blockers live. |
| discuss | Explore a topic, share views, no decision yet | 10 to 20 min | Say what the discussion feeds (a decision next week, a doc). |
| brainstorm | Generate options | 15 to 30 min | Needs a framed question and a method (silent writing first). |
| review | Give feedback on a draft, design, or plan | 15 to 40 min | Send the material beforehand; agree what kind of feedback is wanted. |
| decide | Choose between options and commit | 10 to 30 min | Needs a written proposal, the options, the decision maker, and the decision rule. |
| break | Rest | 5 to 15 min | Every 60 to 90 minutes. |

If an item has no clear outcome, ask: "What will be different after this item?" If the answer is "nothing", cut it.

## How long things really take
- Introductions: about 1 minute per person for a new group, 0 for a regular team.
- Each person speaking once on a topic: about 1.5 to 2 minutes per person. Eight people, one round, is 15 minutes.
- Screen sharing and getting started: 2 to 3 minutes, more for remote meetings with new tools.
- A decision with real disagreement: double the estimate, or split it into "discuss" now and "decide" next time.
- Items late in the agenda run faster but worse: people rush or leave.

## Ordering
1. Start with a short framing: purpose and desired outcomes (2 to 5 min).
2. Decisions and the hardest topics in the first half, while energy and attendance are high.
3. Discussion and review in the middle.
4. Information and updates last or out of the meeting entirely.
5. End with next steps, owners, and dates (5 min), never with AOB.
Group items that need the same people, so others can leave early.

## Buffers and breaks
- Buffer: about 5 minutes per hour, 10 percent for workshops. The buffer is part of the slot, not extra time.
- Breaks: 5 to 10 minutes every 60 to 90 minutes; 15 for longer workshops. Remote meetings need breaks sooner.
- Back-to-back calendars: end 5 minutes early (25 or 50 minute meetings).

## Fit checks
- More than about 8 items per hour means each gets less than 7 minutes; merge or cut.
- Status and announcements above about a third of the time: move them to a pre-read.
- An item over 45 minutes: split it into steps with their own outcomes (for example "brainstorm 25" then "cluster and pick 20").

## The cost of a meeting
Person-hours = attendees x length in hours. A one-hour meeting with 9 people costs 9 working hours. Ask whether everyone needs to be there for every item.
FILE:references/facilitation-moves.md
# Facilitation moves that keep timeboxes

## Before the meeting
- Send the agenda and pre-reads 24 to 48 hours ahead, with the owner of each item.
- For every decision item, send a one-page proposal: the question, options, recommendation, and who decides.
- Ask people to add questions to the pre-read in writing, so the meeting starts from questions, not from a presentation.

## At the start
- State the purpose and the outcomes you need by the end.
- Name a timekeeper and agree on the signal (a message in the chat, a raised hand at 2 minutes left).
- Say how decisions are made today: the owner decides after input, majority vote, or consent ("any strong objection?").

## By item type
- Inform: "Two minutes, then one clarifying question each." Detailed questions go to the parking lot or to a follow-up chat.
- Update: Only blockers and asks live; everything else is in the pre-read.
- Discuss: Frame the question first ("What worries us about option B?"). Use a quick round so everyone speaks once before anyone speaks twice.
- Brainstorm: 3 to 5 minutes of silent writing first, then share, then cluster. Quantity before judgment.
- Review: Agree the kind of feedback: blocking issues, questions, or polish. Collect blocking issues first.
- Decide: Restate the proposal, list concerns, check for objections, then decide and say it out loud: "We decided X; owner Y; by date Z."

## When an item runs over
Make the choice visible instead of silently eating the next item:
1. "We have 2 minutes left on this. Do we extend by 5 minutes and cut something else, or park it?"
2. Park it: write it in the parking lot with an owner and a date.
3. Shrink the next item, never the decision items.
4. If the meeting goal is at risk, schedule a short follow-up with only the people needed.

## Remote and hybrid
- Everyone joins on their own device so remote people are equal.
- Use the chat for the speaking queue and for quick polls.
- Share the agenda on screen with the current item highlighted.

## At the end
- Read back decisions, owners, and dates (5 minutes).
- Review the parking lot: assign each item or drop it.
- Send notes within 24 hours.
FILE:templates/agenda.md
# Agenda input (for 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>
```
A Markdown table with the columns Min, Topic, Owner, Outcome works too, and so does JSON:
`{"meeting": "...", "start": "14:00", "length": 60, "buffer": 5, "attendees": 6, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}`

---

# Agenda to send

**<Meeting name>**
<Day, date>, <start>-<end> (<length> min), <place or video link>
Attendees: <names or teams> | Facilitator: <name> | Timekeeper: <name> | Notes: <name>

**Purpose:** <one sentence: what must be true when we finish>

**Outcomes we need**
1. <decision or result>
2. <decision or result>

**Pre-read (by <day, time>)**
- <document> - <owner> - <what to look at, how long it takes to read>

**Schedule**
| Time | Min | Item | Owner | Outcome |
|---|---|---|---|---|
| <HH:MM-HH:MM> | <n> | <topic> | <owner> | <outcome> |
| <HH:MM-HH:MM> | <n> | Buffer and wrap-up: decisions, owners, dates | <facilitator> | |

**How we decide today:** <owner decides after input | consent | vote>

**Parking lot:** Off-topic points go to the parking lot and get an owner at the end.

**Moved out of this meeting**
- <item> -> <async update, other meeting, or a document>, owner <name>
FILE:examples/example-roadmap-meeting.md
# Example: fixing an overloaded one-hour planning meeting

## The request
"I have one hour with 9 people on Thursday at 14:00 to plan Q4. Here is my agenda. Will it fit? The budget split must be approved in this meeting, and the top 3 initiatives too."

Draft agenda (`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 | |
```

## First run
```
$ 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.
```
Exit code 1: the agenda does not fit.

## What the numbers say
- 70 minutes of items (plus AOB) in a 55-minute window: 15 minutes over before AOB even starts.
- Half of the time is updates and announcements, while the two things that must be decided get 5 minutes (budget) and a "discuss" label (priorities), and the budget decision would start at 15:05, after the meeting should have ended.
- Running `--fit` alone would squeeze everything into 55 minutes but leave the budget decision last with 5 minutes. Fitting the numbers is not enough; the order and the purpose of items have to change.

## Changes
1. Team status round (15 min) and customer feedback highlights (10 min) become a written pre-read due Wednesday 12:00; questions go in comments.
2. The budget split moves to the start with 10 minutes and a proposal sent Monday, so the meeting only confirms or adjusts it.
3. "Q4 priorities" becomes a decision with 25 minutes: the outcome is a ranked top 3.
4. The hiring plan gets an owner (Priya) and a narrower question: which roles to open.
5. AOB is replaced by a 5-minute "next steps, owners, and parking lot" close.
6. Three people only needed for the status round can skip the meeting: 6 attendees instead of 9.

Revised agenda:
```
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
```

## Second run
```
$ 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.
```
Exit code 0, no warnings, and the meeting costs 6 person-hours instead of 9.

## The agenda to send (filled template, shortened)

**Q4 roadmap planning**
Thursday, 14:00-15:00 (60 min), Room 3 and video link
Facilitator: Dana | Timekeeper: Lee | Notes: Priya

**Purpose:** Leave with an approved Q4 budget split and a ranked top 3 of initiatives.

**Pre-read (by Wednesday 12:00)**
- Team status one-pagers - each team lead - 5 min to read
- Customer feedback highlights - Priya - 5 min
- Budget split proposal - Dana - 10 min

**Schedule**
| Time | Min | Item | Owner | Outcome |
|---|---|---|---|---|
| 14:00-14:05 | 5 | Welcome and goals for today | Dana | inform |
| 14:05-14:15 | 10 | Approve Q4 budget split (proposal sent Monday) | Dana | decide |
| 14:15-14:40 | 25 | Q4 priorities: pick top 3 initiatives | Lee | decide |
| 14:40-14:50 | 10 | Hiring plan for Q4: agree which roles to open | Priya | discuss |
| 14:50-14:55 | 5 | Next steps, owners, and parking lot | Dana | inform |
| 14:55-15:00 | 5 | Buffer | | |

**How we decide today:** Dana decides the budget after input; the top 3 is chosen by consent (any strong objection?).

**Facilitation tips**
- Priorities: 3 minutes of silent dot voting on the candidate list, then discuss only the items ranked 2 to 5.
- If the budget item hits 10 minutes, park the open point with an owner and a 24-hour deadline instead of eating into priorities.

**Moved out of this meeting:** team status round and customer feedback (pre-read).

## Why this is a good answer
- It fixes the purpose and the order, not just the minutes.
- Both required decisions now happen in the first 40 minutes, with prepared proposals.
- The script confirms the fit, and the people who were only there for updates get their hour back.
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
Time Zone Meeting Slot Finder
Skill

Finds fair meeting times for distributed teams. Takes each participant's IANA time zone, working days and hours, how far they will stretch, holidays and blocked time, then lists every slot in a date range in each person's local time, handles daylight-saving switches that happen on different weekends, and picks a rotating series so the same people do not always take the early or late call. Includes a stdlib Python zoneinfo script with --self-test.

---
name: time-zone-meeting-slot-finder
description: Finds fair meeting times for distributed teams. Takes each participant's IANA time zone, working days and hours, how far they will stretch, holidays and blocked time, then lists every slot in a date range in each person's local time, handles daylight-saving switches that happen on different weekends, and picks a rotating series so the same people do not always take the early or late call. Includes a stdlib Python zoneinfo script with --self-test.
---

# Time Zone Meeting Slot Finder

You help distributed teams pick meeting times that are possible for everyone required and fair over time. You work in each person's own local time on the actual dates (never with "LA is 9 hours behind Berlin" rules of thumb, which are wrong for weeks every spring and autumn), show the trade-offs openly, and rotate the inconvenient slots instead of always putting them on the same people.

## Files in this skill

- `scripts/slot_finder.py` - reads a team CSV and optional blocked-time CSV, evaluates every slot on a grid over the date range in each participant's local time (Python zoneinfo), ranks slots by who is missing and how much stretch they need, reports DST changes in the range, and can pick a fair rotating series (Python 3.9+ standard library only)
- `references/dst-and-time-zone-pitfalls.md` - DST dates by region, zones that do not switch, half-hour zones, weekends that are not Sat-Sun, abbreviations to avoid, and how calendar invites behave across a switch
- `references/fairness-and-rotation.md` - the pain-point model, how the rotation chooses, when to split a meeting or go async instead, and how to explain the choice to the team
- `templates/slot-proposal.md` - the proposal to send to the team
- `examples/team.csv` - five people in Los Angeles, New York, Berlin, Istanbul and Kolkata (one optional)
- `examples/blocked.csv` - a public holiday, a dentist appointment, a board review and a day of leave

## Workflow

### 1. Collect the facts
For each participant: name, IANA zone (city based, e.g. `America/Los_Angeles`, never "PST"), working days (Mon-Fri, or Sun-Thu in much of the Middle East), working hours, how many hours they are willing to start earlier or finish later, and whether they are required. Also the date range, meeting length, how often it repeats, known holidays and blocked times (in that person's local time). Ask rather than guess zones from a city that has several (e.g. a "Mexico" team member).

### 2. Run the finder
```
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
```
Dates (`--start`, `--end`, `--days`) are read in `--ref-zone` (default UTC). Exit 0 means slots were found, 1 means no slot works for every required person, 2 means bad input (unknown zone, bad time, missing column).

### 3. Read the result
- **DST changes in range** come first: if any zone switches inside the range, the same UTC time lands an hour earlier or later for those people afterwards. Propose slots per period, not one UTC time for the whole range.
- **Best slots** are ranked by: optional people missing, then the worst single person's stretch, then total stretch.
- **Rotating series** picks one meeting per week (or per day) to keep the worst cumulative inconvenience as low as possible, then spreads it. The summary shows how often each person met in hours, stretched or missed.
- If there is no slot, say why (who blocks whom) and offer options: more stretch from volunteers, a shorter meeting, making someone optional, two regional meetings, or async updates.

### 4. Deliver
Fill `templates/slot-proposal.md`: the recommendation with every person's local time and weekday, the DST note, the rotation table, alternatives, and the open questions. Send invites from a calendar with a real zone (the organizer's IANA zone), not "UTC" unless the team agreed, and say which zone anchors the series.

## Example result
Five people, 2026-10-26 to 2026-11-20, Tue-Thu, 60 minutes, series of 4 weekly meetings. Europe left summer time on 25 October and the US on 1 November, so in the week of 26 October New York is only 5 hours behind Berlin instead of 6. Week 1: 13:30 UTC (Ana 06:30 in Los Angeles, a stretch; Priya 19:00 in Kolkata, a stretch; everyone else in hours). After the US change, two weeks at 16:00 UTC (Ana 08:00 in hours, Eva 17:00-18:00 stretch, Bilal 19:00-20:00 stretch) and one at 14:30 UTC (Ana stretches again). Bilal is blocked on 29 October (Republic Day). Priya (optional, +05:30) can join only before the US change; the proposal adds a recorded summary for her and suggests an Asia-friendly slot once a month.

## Rules

- Always use IANA zone names and compute local times per date; never apply fixed offsets across a DST boundary.
- Show every participant's local time and weekday for each proposal, since the date can differ (Kolkata evening, Los Angeles morning).
- Nobody is scheduled outside the stretch they agreed to; ask before assuming someone will take a 06:00 or 22:00 call.
- Rotate inconvenience and show the tally; if one person always loses, say so and suggest a structural fix (split, async, rotate the meeting owner).
- Respect weekends, public holidays and blocked time in the person's own calendar and zone.
- Keep the recommendation short; put the full table in an appendix.
FILE:references/dst-and-time-zone-pitfalls.md
# DST and time zone pitfalls

## Use IANA zone names
- `America/New_York`, `Europe/Berlin`, `Asia/Kolkata`, `Australia/Sydney`. They carry the full history and future rules of that region.
- Avoid abbreviations: "EST", "CST", "IST" and "BST" are ambiguous (IST = India, Ireland or Israel; CST = US Central, China or Cuba) and the zoneinfo entries named EST or MST are fixed offsets with no DST. The script warns on names without a "/".
- Avoid fixed offsets like UTC+1 for people: offsets change twice a year in many places.
- Pick the city that shares the person's rules: Arizona (`America/Phoenix`) has no DST; most of Mexico dropped DST in 2022; `America/Sao_Paulo` dropped DST in 2019.
- Keep the zone database current (`tzdata`); governments change rules with a few weeks' notice.

## DST dates (2026, check each year)
| Region | Summer time starts | Ends | Note |
|---|---|---|---|
| US and Canada (most) | 2nd Sunday of March (2026-03-08) | 1st Sunday of November (2026-11-01) | local 02:00 |
| EU, UK | last Sunday of March (2026-03-29) | last Sunday of October (2026-10-25) | 01:00 UTC, all at once |
| Australia (south-east), New Zealand | first Sunday of October / last Sunday of September | first Sunday of April | southern hemisphere: opposite season |
| No DST | Turkey (UTC+3), India (+05:30), China, Japan, Singapore, UAE, most of Africa, Arizona | | |

So there are two "gap" periods each year when the US-Europe difference is one hour smaller than usual: about 3 weeks in March and 1 week at the end of October / start of November. Recurring meetings anchored in one region move by an hour for the other during those weeks.

## Offsets that are not whole hours
- India +05:30, Iran +03:30, Nepal +05:45, Newfoundland -03:30 (-02:30 in summer), South Australia +09:30/+10:30, Chatham Islands +12:45. A grid on the hour in UTC lands on :30 or :45 for these people; the script evaluates in 5-minute chunks so these are exact.

## Weekends and working days
- Sun-Thu working weeks: e.g. Israel; Fri-Sat weekends in several Gulf countries, while the UAE moved to Mon-Fri (with a half Friday) in 2022. Ask each person.
- Public holidays differ per country and per region (Bavaria vs Berlin, US states). Add them to the blocked file in local dates.
- A Monday 08:00 slot in Los Angeles is Monday evening in India but already Tuesday morning in Sydney or Auckland. Always show the weekday per person.

## DST on the switch day itself
- Spring forward: local times between 02:00 and 03:00 (US) do not exist; autumn back: 01:00-02:00 happens twice. Avoid scheduling in those hours on switch days; the script works on UTC instants so it never produces an invalid time, but humans reading "01:30" on that day can be confused.

## Calendar invites across a switch
- A recurring invite is anchored to the organizer's zone. A 10:00 New York weekly meeting stays at 10:00 in New York and moves for Berlin during the gap weeks.
- If the series must stay fair for both sides, anchor it in UTC deliberately and tell everyone, or plan the gap weeks explicitly.
- Check the invite's zone after creating it (some tools default to the device zone or to "floating" time).
FILE:references/fairness-and-rotation.md
# Fairness and rotation

## Pain-point model used by the script
| Situation for one person in one meeting | Points |
|---|---|
| Entirely inside their working hours | 0 |
| Partly or fully in their stretch window (before or after work, as much as they agreed to) | 2 per hour of stretch, pro rata (30 min = 1) |
| Outside the stretch window, on a non-working day, or during blocked time | cannot attend (slot invalid if they are required) |
| Optional person who cannot attend (rotation tally only) | 3 |

Ranking of single slots: fewest optional people missing, then the lowest worst-off person, then the lowest total.

Rotation (`--series N`): greedily adds one meeting at a time, at most one per ISO week (`--every week`, default) or per date (`--every day`), choosing the slot that keeps the highest cumulative score lowest, then the sum of squared cumulative scores (which spreads pain instead of concentrating it), then fewer people missing. The summary table shows per person: meetings in hours, stretched, missed, total points.

The weights are a model, not a truth. Adjust them with the team: an early start may cost a parent more than a late finish, a 22:00 call costs more than 18:30. Change stretch windows per person to reflect what they actually accept.

## Make the trade-offs visible
- Show each person's local time and weekday for each option.
- Show the rotation tally, so that "Bilal stretches 3 of 4 times" is a visible decision, not an accident.
- Ask people to set their own stretch windows; never assume someone will take a 06:30 call because they did it once.

## When no fair slot exists
- Shorten the meeting: 30 minutes often fits where 60 does not.
- Make people optional and record the meeting with written notes and a decision log.
- Split by region (Americas + Europe, Europe + Asia) with one person bridging, and rotate the bridge.
- Alternate two time slots week by week (e.g. 14:00 UTC and 07:00 UTC) so each side gets the convenient one half the time.
- Go async: written updates, recorded demos, decisions in a doc with a deadline in everyone's local time.

## Checklist before sending
- Every required person is in hours or in their agreed stretch for every meeting in the series.
- Holidays and leave in each country are in the blocked file.
- DST changes in the range are named, and the series accounts for them.
- The invite is created in the agreed anchor zone; times in the message are given per person.
- There is a review date (e.g. after the next DST change) to rotate again.
FILE:templates/slot-proposal.md
# Meeting time proposal: <meeting name>

**Who:** <Ana (Los Angeles), Dana (New York), Eva (Berlin), Bilal (Istanbul), Priya (Kolkata, optional)>
**Length / cadence:** <60 min weekly, Tue-Thu>   **Range checked:** <2026-10-26 to 2026-11-20>
**Anchor zone for the invite:** <UTC / organizer's zone>

## Recommendation
| Week | Start (UTC) | Ana | Dana | Eva | Bilal | Priya |
|---|---|---|---|---|---|---|
| <Oct 26> | <Tue 13:30> | <Tue 06:30 (stretch)> | <09:30> | <14:30> | <16:30> | <19:00 (stretch)> |
| <Nov 2> | <Wed 16:00> | <08:00> | <11:00> | <17:00 (stretch)> | <19:00 (stretch)> | <cannot (21:30)> |

## Daylight saving
- <Europe left summer time on Oct 25, the US on Nov 1: during the week of Oct 26 New York is 5 h behind Berlin instead of 6, so the slot moves by an hour from Nov 2>

## Fairness over the series
| Name | In hours | Stretch | Missed | Points |
|---|---|---|---|---|
| <Ana> | <2> | <2> | <0> | <4> |

<One sentence on who carries the most and what we do about it (e.g. Priya: recorded summary + an Asia-friendly slot monthly).>

## Alternatives
1. <30-minute version at 15:00 UTC: everyone in hours except ...>
2. <alternate two slots week by week>

## Blocked dates taken into account
- <Oct 29: Republic Day, Turkey (Bilal)>

## Please confirm
- <your stretch window is still right>
- <holidays or leave in this range we missed>
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