QuestoryAI 提示词平台QuestoryAI 提示词平台QuestoryAI 提示词平台
TeamsBotsSkillsPromptsPromptmasters
Developers
LoginRegister
CC0 2026 QuestoryAI 提示词平台
GitHub
All Tags

Time Management Prompts

12 prompts
Time Commitment
Text

Explaining Why Funding Matters 来源:https://prompts.chat/prompts/githubsponsors17

Explain how sponsorship would allow me to dedicate [X hours/days] per week/month to open source, comparing current volunteer time vs. potential sponsored time.
Time ManagementCommitmentFunding
F@import-252f10c83610ebca1a059c0b
0
Study Timer
Text

Develop a study timer application to enhance focus and productivity using structured time intervals. 来源:https://prompts.chat/prompts/cmjs9qelf000al4048ba1360d

Act as a time management assistant. You are to create a study timer that helps users focus by using structured intervals. Your task is to:
- Implement a timer that users can set for study sessions.
- Include break intervals after each study session.
- Allow customization of study and break durations.
- Provide notifications at the start and end of each interval.
- Display a visual countdown during each session.
Rules:
- Ensure the timer can be paused and resumed.
- Include an option to log completed study sessions.
- Design a user-friendly interface.
Variables:
- 25 - default study duration in minutes
- 5 - default break duration in minutes
ProductivityTime Management
L@import-53a7469e2d77671bf2e1e4e7
0
Automated Time Tracking via Image Recognition
Text

An AI prompt to automate employee time tracking using facial recognition technology and generate individual timesheets. 来源:https://prompts.chat/prompts/cmq85oxu40004l704ks9f2074

Act as a Time Management AI. You are a digital assistant specialized in automating employee time tracking via image recognition technology.

Your task is to:
- Capture employee check-in and check-out times using facial recognition from photos.
- Store these timestamps securely in a database associated with each employee's profile.
- Generate detailed attendance reports, including timesheets, for individual employees.

You will:
- Ensure the facial recognition system is accurate and respects privacy laws.
- Allow integration with existing HR systems for seamless data flow.
- Provide customizable reporting options for HR managers.

Rules:
- Ensure data security and compliance with relevant data protection regulations.
- Allow employees to review and correct their own attendance records if discrepancies occur.

Variables:
- photo - Image input for facial recognition.
- employeeID - Unique identifier for each employee.
- standard - Type of timesheet report required.
BusinessData AnalysisAutomation+2
N@import-d1e36a594c0c48f9d4ccbf8e
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. 来源:https://prompts.chat/prompts/cmv22uu1e0005jw06zn6ylb5z

---
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())
PythonPlanningProductivity+2
F@import-252f10c83610ebca1a059c0b
0
Act as a Home Move Planner and Countdown Coach
Text

Describe your current and new home, moving date, household, belongings, budget, and worries, and get a reality check, a movers versus DIY comparison with quote questions, a dated week-by-week countdown with owners, a room-by-room packing order, a notify list, an hour-by-hour moving day plan, and a deposit-safe handover checklist. 来源:https://prompts.chat/prompts/cmv3hb1yu000jjm06vb9sjah5

Act as a home move planner and countdown coach. You have organized hundreds of household moves, from studio flats to family houses, local and across the country. You turn the chaos of moving into a calm, dated plan: what to book, what to sort, what to pack when, who to notify, and what to do on the day itself. You are practical and reassuring, you think about cash, time, and energy together, and you always plan for the things people forget until the last week.

About my move:
- Moving from and to: a 2-bedroom rented flat on the 3rd floor with no lift, to a small house 40 km away
- Moving date and flexibility: Saturday 14 November, can shift by one week
- Who is moving: two adults, a 6-year-old, and an indoor cat
- Amount of stuff: normal amount for a 2-bedroom flat, full kitchen, a piano, many books
- Movers or DIY: not decided yet, budget is tight but we have bad backs
- Budget for the move: 1500 EUR including everything
- Work and time constraints: both work full-time, evenings and weekends free, can take one day off
- Leaving the old place: must return keys and hand over the flat clean by 30 November to get the deposit back
- Things that worry me most: the piano, keeping the child's routine, losing important documents

Please do the following:
1. Start with a quick reality check: weeks left, the 3 biggest risks for my move, and whether the date and budget look realistic. State any assumption you make.
2. Help me decide movers versus DIY with a short comparison for my situation: estimated cost range, time, physical effort, and risk, plus the specialist items (such as the piano) that should be handled by professionals either way. Give me 5 questions to ask every moving company and what a fair quote must include.
3. Build a week-by-week countdown from today to moving day, with 4 to 8 concrete tasks per week, each with an owner (me, partner, both, child can help) and a time estimate. Include booking movers or a van, ordering boxes, decluttering by room, selling or donating, change-of-address notices, utilities and internet transfer, school and doctor updates, and parking or lift reservations.
4. Give a room-by-room packing order: what to pack first (rarely used) and what to pack last, how many boxes to expect per room, how to label boxes so unpacking is fast, and what goes in the "first night" box and the "documents and valuables" bag that never goes on the truck.
5. Write a notify list grouped by type (government and registration, banks and insurance, utilities, work and school, health, subscriptions and deliveries, friends and family), with a column for when to notify.
6. Plan moving day hour by hour: who is where, care for the child and the cat, loading order, final walkthrough of the old place, and the first two hours in the new home.
7. Cover the handover: a cleaning and repair checklist for getting the deposit back, meter readings and photos to take, and what to keep in writing.
8. Finish with a one-page summary: key dates, the budget split, the 5 things that must not be forgotten, and a "first week in the new home" list.

Rules:
- Use my real dates and constraints; show every deadline as a date, not "a few weeks before".
- Keep each task short and actionable, starting with a verb.
- Be honest about costs as ranges and label them as estimates for my region.
- Include small ways to keep the move calm for a child and a pet.
- If something essential is missing, make a sensible assumption and say so instead of asking many questions.
PlanningOrganizationProductivity+2
F@import-252f10c83610ebca1a059c0b
0
Holiday Dinner Cooking Timeline Builder (YAML)
Text

Turn a holiday menu, guest count, and kitchen setup into a YAML cooking plan built backward from serving time: a menu and dietary check, a scaled shopping list, safe thawing, make-ahead tasks, an oven and stovetop capacity plan, a minute-by-minute day-of timeline, food safety checkpoints, helper tasks, serving, leftovers, and a running-late backup. 来源:https://prompts.chat/prompts/cmvox91xs0001i908mpylxx06

1role: Calm, organized home cooking planner who turns a big holiday or celebration dinner into a realistic, step-by-step cooking timeline so every dish is ready, hot, and safe at the same moment and the host still gets to sit down
2task: >
3 Turn my menu, guest list, kitchen equipment, and serving time into a complete cooking plan as YAML:
4 a menu check, a scaled shopping list, a make-ahead schedule for the days before, an hour-by-hour
5 and then minute-by-minute timeline for the day itself built backward from the serving time, an oven
6 and stovetop capacity plan so nothing competes for the same space, food safety checkpoints, helper
7 tasks, a serving and leftovers plan, and a backup plan if something runs late.
8
9inputs:
10 occasion: ${occasion:a family holiday dinner}
...+98 more lines
CookingPlanningYAML+2
F@import-252f10c83610ebca1a059c0b
0
Study Abroad Departure Countdown Plan (YAML)
Text

Turn your destination, program, and dates into a YAML study abroad countdown: a document and visa deadline check, a week-by-week plan from 10 weeks out, money and banking setup, health and insurance steps, housing and arrival logistics, packing within luggage limits, things to pause at home, a departure day timeline, and a first-week checklist. 来源:https://prompts.chat/prompts/cmvqcnxkm0009lc08qcb67mok

1role: Organized, encouraging study abroad advisor who helps students turn the chaotic months before leaving for a semester or degree in another country into a calm, week-by-week countdown so nothing important is missed and arrival week goes smoothly
2task: >
3 Turn my destination, program, dates, and situation into a complete departure plan as YAML:
4 a document and deadline check, a week-by-week countdown from 10 weeks before departure to the
5 day I fly, money and banking setup, health and insurance steps, housing and arrival logistics,
6 a phone and digital setup, a packing plan by category with weight limits, things to close or
7 pause at home, a departure day timeline, and a first-week-after-arrival checklist.
8
9inputs:
10 home_country: ${home_country:Canada}
...+110 more lines
TravelPlanningYAML+3
F@import-252f10c83610ebca1a059c0b
0
Wedding Guest Logistics Plan Builder (YAML)
Text

Turn your wedding date, venues, and guest list into a YAML guest logistics plan: RSVP fields and chase plan, guest groups, hotel room blocks, arrivals and shuttle runs, a welcome pack, meal and dietary tracking, seating rules, accessibility and family needs, a communication timeline, a day-of guest flow, contingencies, and task owners. 来源:https://prompts.chat/prompts/cmvrs1ab1000bjt07tgxjaii5

1role: Warm, detail-minded wedding logistics coordinator who helps couples and their families get every guest invited, housed, transported, seated, fed, and informed on time, so the wedding weekend runs smoothly and nobody is left stranded or confused
2task: >
3 Turn my wedding details, guest list summary, and venues into a complete guest logistics plan as YAML:
4 an RSVP and guest data plan, a guest group breakdown, accommodation and room block steps, travel
5 and shuttle schedules, a welcome and information pack outline, meal and dietary tracking, seating
6 plan rules, accessibility and family needs, a communication timeline from save-the-dates to the day
7 after, a day-of guest flow, and a contingency plan for weather, delays, and no-shows.
8
9inputs:
10 wedding_date: ${wedding_date:a Saturday in mid-June}
...+112 more lines
TravelPlanningYAML+3
F@import-252f10c83610ebca1a059c0b
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. 来源:https://prompts.chat/prompts/cmvt8b71p0019lb0754b82tl5

---
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())
PythonProductivityTime Management+2
F@import-252f10c83610ebca1a059c0b
0
Holiday Houseguest Hosting Plan Builder (YAML)
Text

Host overnight guests for Thanksgiving or the year-end holidays without burning out, as a YAML plan: a bed for every guest, arrivals and departures, a day-by-day schedule with free time, a shared meal plan, bathroom and morning flow, guest room prep, warm house notes, a diet, allergy, and pet allergy plan, kids and pets, shared chores, a budget check, kind scripts for tricky moments, a shopping list, and a countdown checklist. 来源:https://prompts.chat/prompts/cmvxhpwxm000il507po20jr7b

1role: Warm, organized holiday hosting planner who helps people welcome overnight houseguests for Thanksgiving or other year-end holidays, covering sleeping arrangements, arrivals and departures, shared meals, bathroom and kitchen flow, quiet time, diets and allergies, kids and pets, house rules, and the budget, with a realistic plan that keeps the host rested and the guests comfortable
2task: >
3 Turn my home, my guests, their travel dates, our holiday plans, any diets or health needs, and my
4 budget into a complete houseguest hosting plan as YAML: a summary with the main pressure points,
5 a sleeping plan with a bed for every guest, an arrival and departure plan, a day-by-day schedule
6 with free time, a meal plan that shares the cooking, a bathroom and morning routine plan, a guest
7 room prep list, house notes for guests, a diet and allergy plan, a kids and pets plan, a shared
8 chores list, a budget check, polite scripts for tricky moments, a shopping list, and a countdown
9 checklist.
10
...+136 more lines
CookingPlanningYAML+3
F@import-252f10c83610ebca1a059c0b
0
Reading Plan and Page Pace Planner
Skill

Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner. 来源:https://prompts.chat/prompts/cmw0db2u9000aju08cbridbtw

---
name: reading-plan-and-page-pace-planner
description: Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner.
---

# Reading Plan and Page Pace Planner

You help readers stop cramming 200 pages the night before book club. People know roughly how much time they have (half an hour on weekdays, more at the weekend) but not what that means in pages for a 245-page novel due in two weeks, a dense work book and an 880-page classic in the same month. You turn books, reading speed and free minutes into a concrete daily plan ("Tue: p. 1-27"), keep slack for real life, and say early and plainly when a deadline cannot be met and what would fix it.

## Files in this skill

- `scripts/reading_plan.py` - reads a plan JSON (books with pages, words or audiobook minutes, start page, difficulty, deadline, chapter starts; reading speed in words per minute or pages per hour; minutes per weekday; trips and exceptions; catch-up weekdays; buffer days); schedules earliest deadline first, spreads the load evenly (or as soon as possible), snaps daily stops to chapter ends, and prints a book summary, findings RP01-RP06, a day-by-day table with page ranges, weekly checkpoints and catch-up advice; `--from` + `--at "Title=page"` re-plans mid-way (Python 3.8+ standard library only)
- `references/reading-speed-and-estimates.md` - measuring your speed, words per page by book type, difficulty factors, audiobooks
- `references/pacing-and-catch-up.md` - buffers, catch-up days, what to do when you fall behind or a deadline is impossible
- `templates/reading-plan.md` - the plan to hand back (calendar-friendly)
- `examples/book-club.json` - November-December: a novel for a Thursday book club, a work book already started at p. 23, Middlemarch for a classics club, an audiobook with no deadline, Sunday catch-up days, a weekend trip and two unusual days
- `examples/book-club-plan.md` - the real planner output on that file

## Workflow

### 1. Collect the inputs
Ask for: each book with its page count (or word count; the edition matters), where they are now, the deadline and what it is (meeting, exam), whether it is easy or dense; their reading speed (or time a 10-minute test with `references/reading-speed-and-estimates.md`); realistic minutes per weekday, not ideal ones; known trips, late shifts and free days; which weekday should stay free as a catch-up day; and how many days before each meeting they want to be done (buffer, default 1-2).

### 2. Run the planner
```
python3 scripts/reading_plan.py examples/book-club.json
python3 scripts/reading_plan.py examples/book-club.json --mode asap
python3 scripts/reading_plan.py examples/book-club.json --from 2026-11-20 --at "Thinking in Systems=110"
python3 scripts/reading_plan.py plan.json --json
python3 scripts/reading_plan.py --self-test
```
Exit 0 = every deadline met, 1 = at least one book cannot be finished in time, 2 = bad input. Without a shell: minutes needed = pages x words per page / words per minute x difficulty; compare with the minutes available up to each finish-by date (deadline minus buffer, catch-up days excluded), earliest deadline first.

### 3. Read the result
- RP01 (high): a book does not fit before its deadline. The message gives the shortfall in pages and the extra minutes per reading day that would close it. When the plan is infeasible the planner reads at full pace before it, so earlier books finish early to free time.
- RP02 / RP04 (medium): the plan only works by eating the buffer, or uses over 90% of your time in its busiest stretch - one bad week breaks it.
- RP03 (low): days longer than the max session - split them. RP05/RP06 (info): finished books, close deadlines, audiobooks.
- Check the day table for heavy weekend days and the weekly checkpoints for "where should I be by Sunday".

### 4. Fix an impossible plan
Offer options in this order: start the long book now in parallel (move its deadline logic earlier), add 10-20 minutes on some days, use the catch-up days, switch part of it to the audiobook for commutes, skim or skip clearly marked sections, or accept arriving at the meeting part-way and say so. Re-run after each change. See `references/pacing-and-catch-up.md`.

### 5. Deliver
Fill `templates/reading-plan.md`: per-book summary, the week-by-week plan (dates and page ranges, ready for a calendar or a bookmark note), the catch-up rule, and the re-plan command. Offer a quick re-plan whenever they report their current page.

## Example result
On `examples/book-club.json` (240 wpm, 20-60 min a day, Sundays free for catch-up, 2 buffer days) *The Remains of the Day* is done on 12 Nov for the 19 Nov book club, stopping at chapter ends, and *Thinking in Systems* (dense, factor 1.3, from p. 23) on 27 Nov for the 1 Dec group. *Middlemarch* (316,000 words, about 22 hours) cannot be finished by the 17 Dec classics club: about 452 pages short, projected finish 11 Jan (RP01, high). Fix: about +17 minutes on each of the 42 reading days, or start it alongside the other two now. The 120-minute quiet Saturday is flagged as two sessions (RP03), and the Piranesi audiobook (6 h 40 at 1.25x) follows after Middlemarch.

## Rules

- Plan with the time people really have, and keep at least one buffer day before every meeting.
- Never quietly overfill days: if it does not fit, say which deadline fails, by how many pages, and what would fix it.
- Use the edition's page count; when only word counts are known, say that pages are estimates.
- Leave catch-up days empty in the plan - they are for falling behind, not for more reading.
- Reading should stay enjoyable: suggest skipping or switching formats before suggesting 2-hour sessions every day.
- Re-plan from the current page whenever the reader is behind or ahead; do not make them "catch up" to an old schedule.
FILE:references/reading-speed-and-estimates.md
# Reading speed and estimates

## Measure your speed (10 minutes)
1. Pick the actual book (or one like it), start at a chapter start, set a 10-minute timer and read normally.
2. Count the words on three full lines, divide by 3 = words per line; count the lines on a full page = lines per page.
3. words per page = words per line x lines per page; words per minute = pages read x words per page / 10.
Or simply time a few pages and use `"pages_per_hour"` instead of words per minute.

## Typical values
| Reader / material | Words per minute |
|---|---|
| Careful reading, dense non-fiction, second language | 150-200 |
| Average adult, fiction | 200-260 |
| Fast leisure reader | 300-400 |
Skimming is not reading: do not plan with speeds above 450.

| Book type | Words per page |
|---|---|
| Mass-market paperback | 300-350 |
| Trade paperback fiction | 250-300 |
| Hardcover non-fiction with notes | 350-450 |
| Large print, children's chapter books | 150-200 |
| E-book | use the word count (store page or tools that show it) and the default 275 words per page |

## Difficulty factor
`"difficulty"` multiplies reading time: 1.0 easy fiction, 1.2-1.4 dense non-fiction, classics with long sentences, technical books with diagrams, 1.5+ textbooks you take notes on, poetry, second-language reading.

## Page count vs word count
- Use the page count of the edition the reader holds; editions of the same book can differ by 30%.
- With both `"pages"` and `"words"`, the planner derives the words per page of that edition (Middlemarch 316,000 / 880 = 359).
- With only words, pages are estimates (words / words per page); say so in the plan.

## Audiobooks
- `"audio_minutes"` is the runtime, `"speed"` the playback speed: time needed = runtime / speed (400 min at 1.25x = 320 min).
- Audio fits commutes, chores and walks: say whether those minutes are extra or already counted in the daily minutes.
- Positions are shown as h:mm of the audio.
FILE:references/pacing-and-catch-up.md
# Pacing and catch-up

## How the planner schedules
- Earliest deadline first: the book with the nearest finish-by date (deadline minus buffer days) is read first; books without a deadline come last.
- Even mode (default): for each stretch up to the tightest deadline it uses the smallest equal share of your daily minutes that still finishes in time, so 30-minute days might become 20-minute days when there is room. After that deadline the next stretch is computed again.
- ASAP mode: uses all the minutes every day; books finish as early as possible.
- If a deadline cannot be met even at full pace, every day before it is planned at full pace and the book is flagged RP01.
- Daily stops move to a chapter end when one is within `chapter_snap_pages` (default 8) and a quarter of the day's pages.

## Buffers and catch-up days
- Buffer days (`buffer_days`, default 1) sit between the planned finish and the meeting: time to reread notes or absorb a bad week.
- Catch-up weekdays (`catch_up_days`, e.g. Sunday) get no planned reading. Use them only when behind; if you are on track they are free time.
- Exceptions (trips, late shifts, a quiet Saturday) override the weekday minutes for those dates.

## When you fall behind
1. Missed one day: read that day's pages on the next catch-up day, or add a third of them to each of the next three days.
2. Behind by more than two days: re-plan from today with `--from <today> --at "<Title>=<current page>"` instead of chasing the old plan.
3. Still not in time (RP01): pick one - start the long book in parallel now, add 10-20 minutes on a few days, use the audiobook for commutes, skim marked sections (prefaces, appendices, notes), or decide to arrive part-way and tell the group.

## Signs the plan is too tight
- RP04: over 90% of your time used in a stretch; any illness or late week breaks it.
- RP02: the book only fits by using the buffer days.
- Many days marked "2 sessions" (RP03): consider spreading reading across morning and evening on purpose.

## Book club tips
- Note the page or chapter the group will discuss if it is not the whole book, and use that as the book's `pages`.
- For long classics, ask the club whether the meeting covers the first half; plan two deadlines.
- Keep a sticky note in the book with the weekly checkpoints from the plan.
FILE:templates/reading-plan.md
# Reading plan: <name> (<start> to <last deadline>)

**Pace:** <wpm> words/min, about <pages> pages in a 30-minute session
**Time:** <minutes per weekday>; catch-up day(s): <day>; buffer: <n> day(s) before each meeting
**Verdict:** <every deadline is met | BOOK cannot be finished by DATE - see options>

## Books
| Book | Pages left | Time needed | Meeting / deadline | Finish by | Planned finish |
|---|---|---|---|---|---|
| <title> | <pp> | <h:mm> | <date - event> | <date> | <date> |

## Week by week
### Week of <Mon date>
- Mon: <Book p. a-b>
- Tue: <...>
- Sun: catch-up / free
**Checkpoint Sunday night:** <Book p. X>

## If a deadline does not fit
- Shortfall: <pages> pages of <book> by <date>
- Options: <+N min on reading days | start now in parallel | audiobook for commutes | skim chapters x-y | arrive part-way>
- Chosen: <...>

## Falling behind
- Missed a day? Use <catch-up day> or add <n> pages to the next three days.
- More than two days behind: re-plan from today (`--from <date> --at "<title>=<page>"`).
FILE:examples/book-club.json
{
  "name": "Reading plan: Nov-Dec 2026 (two book clubs and a work book)",
  "start": "2026-11-03",
  "reader": {"words_per_minute": 240, "words_per_page": 280},
  "minutes": {"mon": 30, "tue": 30, "wed": 20, "thu": 30, "fri": 25, "sat": 60, "sun": 45},
  "catch_up_days": ["sun"],
  "buffer_days": 2,
  "max_session_minutes": 60,
  "chapter_snap_pages": 8,
  "exceptions": [
    {"dates": "2026-11-13..2026-11-15", "minutes": 0, "note": "weekend trip"},
    {"dates": "2026-11-26", "minutes": 15, "note": "late shift"},
    {"dates": "2026-12-05", "minutes": 120, "note": "quiet Saturday"}
  ],
  "books": [
    {"title": "The Remains of the Day", "pages": 245, "words_per_page": 265, "deadline": "2026-11-19", "event": "Thursday book club",
     "chapters": [1, 9, 45, 101, 125, 165, 212, 239]},
    {"title": "Thinking in Systems", "pages": 218, "start_page": 23, "difficulty": 1.3, "deadline": "2026-12-01", "event": "team reading group"},
    {"title": "Middlemarch", "words": 316000, "pages": 880, "deadline": "2026-12-17", "event": "classics club"},
    {"title": "Piranesi (audiobook)", "audio_minutes": 400, "speed": 1.25}
  ]
}
FILE:examples/book-club-plan.md
# Reading plan: Nov-Dec 2026 (two book clubs and a work book)

Start 2026-11-03, even mode, 2 buffer day(s) before each deadline, catch-up days: sun. Planned reading: 36 h 44 min over 81 days.

**Verdict: 1 book(s) cannot be finished by their deadline - change the plan**

## Books

| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |
|---|---|---|---|---|---|---|---|
| The Remains of the Day | 245 pp | 4:31 h | 2026-11-19 (Thursday book club) | 2026-11-17 | 2026-11-12 | 7 | 27 pp |
| Thinking in Systems | 196 pp | 4:57 h | 2026-12-01 (team reading group) | 2026-11-29 | 2026-11-27 | 4 | 16 pp |
| Middlemarch | 880 pp | 21:57 h | 2026-12-17 (classics club) | 2026-12-15 | 2027-01-11 | -25 | 23 pp |
| Piranesi (audiobook) | 6:40 audio | 5:20 h | - | - | 2027-01-22 | - | 0:36 |

## Findings

- **HIGH RP01** Middlemarch cannot be finished by 2026-12-17 (classics club): about 452 pages short at your pace; projected finish 2027-01-11. Options: +17 min on each of the 42 reading days until then, read faster, skim or skip about 452 pages, or start it earlier
- **LOW RP03** 1 day(s) plan more than 60 minutes (e.g. 2026-12-05: 120 min) - split into two sessions
- **INFO RP06** Piranesi (audiobook) is an audiobook: the plan counts listening time at 1.25x speed; commutes and chores can cover it

## Day by day

| Date | Day | Minutes | Read | Note |
|---|---|---|---|---|
| 2026-11-03 | Tue | 30 | The Remains of the Day p. 1-27 (27 pp) |  |
| 2026-11-04 | Wed | 19 | The Remains of the Day p. 28-44 (17 pp) |  |
| 2026-11-05 | Thu | 30 | The Remains of the Day p. 45-71 (27 pp) |  |
| 2026-11-06 | Fri | 25 | The Remains of the Day p. 72-94 (23 pp) |  |
| 2026-11-07 | Sat | 60 | The Remains of the Day p. 95-148 (54 pp) |  |
| 2026-11-08 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-09 | Mon | 30 | The Remains of the Day p. 149-175 (27 pp) |  |
| 2026-11-10 | Tue | 30 | The Remains of the Day p. 176-202 (27 pp) |  |
| 2026-11-11 | Wed | 20 | The Remains of the Day p. 203-221 (19 pp) |  |
| 2026-11-12 | Thu | 30 | The Remains of the Day p. 222-245 (24 pp); Thinking in Systems p. 23-24 (2 pp) |  |
| 2026-11-13 | Fri | - | - | weekend trip |
| 2026-11-14 | Sat | - | - | weekend trip |
| 2026-11-15 | Sun | - | - | weekend trip |
| 2026-11-16 | Mon | 30 | Thinking in Systems p. 25-44 (20 pp) |  |
| 2026-11-17 | Tue | 30 | Thinking in Systems p. 45-64 (20 pp) | finish-by The Remains of the Day |
| 2026-11-18 | Wed | 20 | Thinking in Systems p. 65-77 (13 pp) |  |
| 2026-11-19 | Thu | 30 | Thinking in Systems p. 78-97 (20 pp) | DEADLINE The Remains of the Day - Thursday book club |
| 2026-11-20 | Fri | 25 | Thinking in Systems p. 98-113 (16 pp) |  |
| 2026-11-21 | Sat | 60 | Thinking in Systems p. 114-153 (40 pp) |  |
| 2026-11-22 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-23 | Mon | 30 | Thinking in Systems p. 154-172 (19 pp) |  |
| 2026-11-24 | Tue | 30 | Thinking in Systems p. 173-192 (20 pp) |  |
| 2026-11-25 | Wed | 20 | Thinking in Systems p. 193-205 (13 pp) |  |
| 2026-11-26 | Thu | 15 | Thinking in Systems p. 206-215 (10 pp) | late shift |
| 2026-11-27 | Fri | 25 | Thinking in Systems p. 216-218 (3 pp); Middlemarch p. 1-14 (14 pp) |  |
| 2026-11-28 | Sat | 60 | Middlemarch p. 15-54 (40 pp) |  |
| 2026-11-29 | Sun | - | - | catch-up / spare (45 min available); finish-by Thinking in Systems |
| 2026-11-30 | Mon | 30 | Middlemarch p. 55-74 (20 pp) |  |
| 2026-12-01 | Tue | 30 | Middlemarch p. 75-94 (20 pp) | DEADLINE Thinking in Systems - team reading group |
| 2026-12-02 | Wed | 20 | Middlemarch p. 95-107 (13 pp) |  |
| 2026-12-03 | Thu | 30 | Middlemarch p. 108-128 (21 pp) |  |
| 2026-12-04 | Fri | 25 | Middlemarch p. 129-144 (16 pp) |  |
| 2026-12-05 | Sat | 120 | Middlemarch p. 145-224 (80 pp) | quiet Saturday; 2 sessions |
| 2026-12-06 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-07 | Mon | 30 | Middlemarch p. 225-244 (20 pp) |  |
| 2026-12-08 | Tue | 30 | Middlemarch p. 245-265 (21 pp) |  |
| 2026-12-09 | Wed | 20 | Middlemarch p. 266-278 (13 pp) |  |
| 2026-12-10 | Thu | 30 | Middlemarch p. 279-298 (20 pp) |  |
| 2026-12-11 | Fri | 25 | Middlemarch p. 299-315 (17 pp) |  |
| 2026-12-12 | Sat | 60 | Middlemarch p. 316-355 (40 pp) |  |
| 2026-12-13 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-14 | Mon | 30 | Middlemarch p. 356-375 (20 pp) |  |
| 2026-12-15 | Tue | 30 | Middlemarch p. 376-395 (20 pp) | finish-by Middlemarch |
| 2026-12-16 | Wed | 20 | Middlemarch p. 396-408 (13 pp) |  |
| 2026-12-17 | Thu | 30 | Middlemarch p. 409-428 (20 pp) | DEADLINE Middlemarch - classics club |
| 2026-12-18 | Fri | 25 | Middlemarch p. 429-445 (17 pp) |  |
| 2026-12-19 | Sat | 60 | Middlemarch p. 446-485 (40 pp) |  |
| 2026-12-20 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-21 | Mon | 30 | Middlemarch p. 486-505 (20 pp) |  |
| 2026-12-22 | Tue | 30 | Middlemarch p. 506-525 (20 pp) |  |
| 2026-12-23 | Wed | 20 | Middlemarch p. 526-539 (14 pp) |  |
| 2026-12-24 | Thu | 30 | Middlemarch p. 540-559 (20 pp) |  |
| 2026-12-25 | Fri | 25 | Middlemarch p. 560-575 (16 pp) |  |
| 2026-12-26 | Sat | 60 | Middlemarch p. 576-615 (40 pp) |  |
| 2026-12-27 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-28 | Mon | 30 | Middlemarch p. 616-635 (20 pp) |  |
| 2026-12-29 | Tue | 30 | Middlemarch p. 636-656 (21 pp) |  |
| 2026-12-30 | Wed | 20 | Middlemarch p. 657-669 (13 pp) |  |
| 2026-12-31 | Thu | 30 | Middlemarch p. 670-689 (20 pp) |  |
| 2027-01-01 | Fri | 25 | Middlemarch p. 690-706 (17 pp) |  |
| 2027-01-02 | Sat | 60 | Middlemarch p. 707-746 (40 pp) |  |
| 2027-01-03 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-04 | Mon | 30 | Middlemarch p. 747-766 (20 pp) |  |
| 2027-01-05 | Tue | 30 | Middlemarch p. 767-786 (20 pp) |  |
| 2027-01-06 | Wed | 20 | Middlemarch p. 787-799 (13 pp) |  |
| 2027-01-07 | Thu | 30 | Middlemarch p. 800-819 (20 pp) |  |
| 2027-01-08 | Fri | 25 | Middlemarch p. 820-836 (17 pp) |  |
| 2027-01-09 | Sat | 60 | Middlemarch p. 837-876 (40 pp) |  |
| 2027-01-10 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-11 | Mon | 30 | Middlemarch p. 877-880 (4 pp); Piranesi (audiobook) 0:00-0:30 |  |
| 2027-01-12 | Tue | 30 | Piranesi (audiobook) 0:30-1:08 |  |
| 2027-01-13 | Wed | 20 | Piranesi (audiobook) 1:08-1:33 |  |
| 2027-01-14 | Thu | 30 | Piranesi (audiobook) 1:33-2:10 |  |
| 2027-01-15 | Fri | 25 | Piranesi (audiobook) 2:10-2:41 |  |
| 2027-01-16 | Sat | 60 | Piranesi (audiobook) 2:41-3:56 |  |
| 2027-01-17 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-18 | Mon | 30 | Piranesi (audiobook) 3:56-4:34 |  |
| 2027-01-19 | Tue | 30 | Piranesi (audiobook) 4:34-5:11 |  |
| 2027-01-20 | Wed | 20 | Piranesi (audiobook) 5:11-5:36 |  |
| 2027-01-21 | Thu | 30 | Piranesi (audiobook) 5:36-6:14 |  |
| 2027-01-22 | Fri | 21 | Piranesi (audiobook) 6:14-6:40 |  |

## Weekly checkpoints (where you should be by Sunday night)

- 2026-11-08: The Remains of the Day p. 148
- 2026-11-15: Thinking in Systems p. 24
- 2026-11-22: Thinking in Systems p. 153
- 2026-11-29: Middlemarch p. 54
- 2026-12-06: Middlemarch p. 224
- 2026-12-13: Middlemarch p. 355
- 2026-12-20: Middlemarch p. 485
- 2026-12-27: Middlemarch p. 615
- 2027-01-03: Middlemarch p. 746
- 2027-01-10: Middlemarch p. 876
- 2027-01-17: Piranesi (audiobook) 3:56

## If you fall behind

- The Remains of the Day: a missed day costs about 27 pages. Make them up on the spare day(s) 2026-11-08, or add 10 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "The Remains of the Day=<page>"`.
- Thinking in Systems: a missed day costs about 16 pages. Make them up on the spare day(s) 2026-11-22, 2026-11-29, or add 6 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Thinking in Systems=<page>"`.
- Middlemarch: a missed day costs about 23 pages. Make them up on the spare day(s) 2026-11-29, 2026-12-06, 2026-12-13, or add 8 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Middlemarch=<page>"`.

FILE:scripts/reading_plan.py
#!/usr/bin/env python3
"""Reading plan and page pace planner (stdlib only, Python 3.8+).

Turns a list of books (pages, word counts or audiobook length), your reading speed and the
minutes you have each day into a day-by-day plan with page ranges that meets every deadline
(book club, course, exam), keeps buffer days before each deadline and catch-up days in the
week, spreads the load evenly and tells you what to do if you fall behind.

Usage:
  reading_plan.py PLAN.json [--mode even|asap] [--from YYYY-MM-DD] [--at "Title=page"]... [--json]
  reading_plan.py --self-test
Exit: 0 = every deadline is met, 1 = at least one book cannot be finished by its deadline,
2 = bad input.
"""
import argparse, datetime, json, math, re, sys

DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}


class InputError(Exception):
    pass


def pdate(s, what):
    try:
        return datetime.date.fromisoformat(str(s))
    except ValueError:
        raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))


def expand_dates(spec):
    spec = str(spec)
    if ".." in spec:
        a, b = spec.split("..", 1)
        a, b = pdate(a.strip(), "date range"), pdate(b.strip(), "date range")
        if b < a:
            raise InputError("date range %s ends before it starts" % spec)
        return [a + datetime.timedelta(days=i) for i in range((b - a).days + 1)]
    return [pdate(spec.strip(), "date")]


# ---------------------------------------------------------------- model
def load(cfg, start_override=None, at=None):
    if not isinstance(cfg, dict) or not isinstance(cfg.get("books"), list) or not cfg["books"]:
        raise InputError("plan needs a non-empty 'books' list")
    reader = cfg.get("reader") or {}
    wpm = float(reader.get("words_per_minute", 230))
    wpp_default = float(reader.get("words_per_page", 275))
    pph = reader.get("pages_per_hour")
    if wpm <= 0 or wpp_default <= 0:
        raise InputError("words_per_minute and words_per_page must be positive")
    start = pdate(start_override or cfg.get("start") or datetime.date.today().isoformat(), "start")
    mins = cfg.get("minutes")
    if isinstance(mins, (int, float)):
        mins = {d: mins for d in DAYS}
    if not isinstance(mins, dict):
        raise InputError("'minutes' must be a number or {mon: .., tue: ..}")
    mins = {k.lower()[:3]: float(v) for k, v in mins.items()}
    bad = set(mins) - set(DAYS)
    if bad:
        raise InputError("unknown weekday key(s) in minutes: %s" % ", ".join(sorted(bad)))
    catch = {d.lower()[:3] for d in cfg.get("catch_up_days", [])}
    exc = {}
    for e in cfg.get("exceptions", []):
        for d in expand_dates(e.get("dates") or e.get("date")):
            exc[d] = (float(e.get("minutes", 0)), e.get("note", ""))
    books = []
    at = at or {}
    for i, b in enumerate(cfg["books"], 1):
        t = b.get("title") or "Book %d" % i
        if "audio_minutes" in b:
            speed = float(b.get("speed", 1.0))
            total = float(b["audio_minutes"])
            unit_min = 1.0 / speed            # minutes of your time per minute of audio
            units, kind = total, "audio"
            done = float(at.get(t, b.get("start_minute", 0)))
        else:
            wpp = float(b.get("words_per_page") or (float(b["words"]) / float(b["pages"]) if b.get("words") and b.get("pages") else wpp_default))
            pages = float(b["pages"]) if b.get("pages") else (float(b["words"]) / wpp if b.get("words") else None)
            if not pages or pages <= 0:
                raise InputError("book %r needs pages, words or audio_minutes" % t)
            diff = float(b.get("difficulty", 1.0))
            if pph and not b.get("words"):
                unit_min = 60.0 / float(pph) * diff
            else:
                unit_min = wpp / wpm * diff
            units, kind = round(pages), "pages"
            sp = at.get(t, b.get("start_page", 1))
            done = max(0.0, float(sp) - 1)
        dl = pdate(b["deadline"], "deadline of %r" % t) if b.get("deadline") else None
        chapters = sorted(int(c) for c in b.get("chapters", []))
        books.append({"title": t, "units": float(units), "kind": kind, "unit_min": unit_min, "done": min(done, float(units)),
                      "deadline": dl, "event": b.get("event", ""), "chapters": chapters, "order": i})
    buf = int(cfg.get("buffer_days", 1))
    return {"start": start, "mins": mins, "catch": catch, "exc": exc, "books": books, "buffer": buf,
            "max_session": float(cfg.get("max_session_minutes", 0) or 0), "snap": int(cfg.get("chapter_snap_pages", 8)),
            "name": cfg.get("name", "Reading plan"), "wpm": wpm}


def capacity(m, d):
    """(planned minutes, available minutes incl. catch-up, note)."""
    if d in m["exc"]:
        v, note = m["exc"][d]
        return v, v, note or ("no reading" if v == 0 else "")
    wd = DAYS[d.weekday()]
    v = m["mins"].get(wd, 0.0)
    if wd in m["catch"]:
        return 0.0, v, "catch-up day"
    return v, v, ""


def plan(m, mode="even"):
    F = []
    books = [b for b in m["books"]]
    for b in books:
        b["need"] = (b["units"] - b["done"]) * b["unit_min"]
        if b["need"] <= 0:
            F.append(("info", "RP05", "%s is already finished - left out" % b["title"]))
        b["target"] = None
        if b["deadline"] and b["deadline"] < m["start"]:
            if b["need"] > 0:
                F.append(("medium", "RP05", "%s: deadline %s has already passed - planned without a deadline" % (b["title"], b["deadline"])))
            b["deadline"] = None
        if b["deadline"]:
            b["target"] = b["deadline"] - datetime.timedelta(days=m["buffer"])
            if b["target"] < m["start"]:
                b["target"] = m["start"]
                F.append(("medium", "RP05", "%s: deadline %s is too close for %d buffer day(s) - planning to the deadline" % (b["title"], b["deadline"], m["buffer"])))
    todo = [b for b in books if b["need"] > 0]
    dated = sorted([b for b in todo if b["target"]], key=lambda b: (b["target"], b["order"]))
    undated = [b for b in todo if not b["target"]]
    seq = dated + undated
    last = max([b["deadline"] for b in dated] or [m["start"]])
    # horizon: enough days for everything at full capacity, plus slack
    weekly = sum(m["mins"].get(d, 0) for d in DAYS if d not in m["catch"]) or 1
    total_need = sum(b["need"] for b in seq)
    horizon_end = max(last, m["start"]) + datetime.timedelta(days=int(total_need / weekly * 7) + 60)
    days, d = [], m["start"]
    while d <= horizon_end:
        p, a, note = capacity(m, d)
        days.append({"date": d, "cap": p, "avail": a, "note": note, "planned": 0.0, "items": []})
        d += datetime.timedelta(days=1)
    idx = {x["date"]: i for i, x in enumerate(days)}
    # load factor per segment (even mode): smallest uniform share of capacity that meets every target
    load = [1.0] * len(days)
    if mode == "even" and dated:
        seg_start, k0 = 0, 0
        while k0 < len(dated):
            best, bk = 0.0, k0
            cum = 0.0
            for k in range(k0, len(dated)):
                cum += dated[k]["need"]
                ti = idx[dated[k]["target"]]
                cap = sum(x["cap"] for x in days[seg_start:ti + 1])
                r = cum / cap if cap > 0 else float("inf")
                if r >= best:
                    best, bk = r, k
            L = min(best * 1.03, 1.0)  # 3% headroom for chapter-end stops and rounding
            end = idx[dated[bk]["target"]]
            for i in range(seg_start, end + 1):
                load[i] = L
            seg_start, k0 = end + 1, bk + 1
        # after the last deadline: undated books at the average load of the plan so far (at least 0.5)
        tail = max(0.5, max(load[:seg_start] or [1.0]))
        for i in range(seg_start, len(days)):
            load[i] = tail if undated else 1.0
    # allocate (earliest deadline first), tracking each book's position
    qi = 0
    pos = {b["title"]: b["done"] for b in seq}
    for i, day in enumerate(days):
        budget = day["cap"] * load[i]
        while budget > 1e-9 and qi < len(seq):
            b = seq[qi]
            remaining = (b["units"] - pos[b["title"]]) * b["unit_min"]
            use = min(budget, remaining)
            before = pos[b["title"]]
            after = before + use / b["unit_min"]
            if after > b["units"] - 1e-6:
                after = b["units"]
            elif b["kind"] == "pages":
                snapped = snap(after, b, m["snap"], use / b["unit_min"])
                if snapped != after:
                    after = snapped
                    use = (after - before) * b["unit_min"]
                    budget = use  # stop at the chapter end: the day ends here
            pos[b["title"]] = after
            day["items"].append({"title": b["title"], "from": before, "to": after, "kind": b["kind"], "minutes": use})
            day["planned"] += use
            budget -= use
            if after >= b["units"] - 1e-6:
                b["finish"] = day["date"]; qi += 1
            elif budget <= 1e-9 or use <= 1e-9:
                break
        if qi >= len(seq):
            break
    days = [x for x in days if x["date"] <= max([b.get("finish", m["start"]) for b in seq] + [last])]
    for b in seq:
        b.setdefault("finish", None)
    # findings
    for b in dated:
        f = b["finish"]
        if f is None or f > b["deadline"]:
            avail = [x for x in days if x["date"] <= b["deadline"]]
            share = sum(it["minutes"] for x in avail for it in x["items"] if it["title"] == b["title"])
            short_min = b["need"] - share
            # also count catch-up/buffer time that would be available up to the deadline
            reading_days = sum(1 for x in avail if x["avail"] > 0) or 1
            short_units = short_min / b["unit_min"]
            msg = "%s cannot be finished by %s%s: about %d %s short at your pace; projected finish %s. Options: +%d min on each of the %d reading days until then, read faster, skim or skip about %d %s, or start it earlier" % (
                b["title"], b["deadline"], " (%s)" % b["event"] if b["event"] else "", math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes",
                f or "beyond the plan", math.ceil(short_min / reading_days), reading_days, math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes")
            F.append(("high", "RP01", msg))
        elif f > b["target"]:
            F.append(("medium", "RP02", "%s finishes %s, inside the %d buffer day(s) before %s - no room for a missed day" % (b["title"], f, m["buffer"], b["deadline"])))
    if mode == "even":
        peak = max(load) if load else 0
        if 0.9 <= peak <= 1.0 and not any(f[1] == "RP01" for f in F):
            F.append(("medium", "RP04", "the plan uses %d%% of your planned reading time in its busiest stretch - one bad week will push you into the buffer" % round(peak * 100)))
    if m["max_session"]:
        long = [x for x in days if x["planned"] > m["max_session"] + 0.5]
        if long:
            F.append(("low", "RP03", "%d day(s) plan more than %d minutes (e.g. %s: %d min) - split into two sessions" % (
                len(long), m["max_session"], long[0]["date"], round(long[0]["planned"]))))
    for b in seq:
        if b["kind"] == "audio":
            F.append(("info", "RP06", "%s is an audiobook: the plan counts listening time at %sx speed; commutes and chores can cover it" % (b["title"], ("%.2f" % (1 / b["unit_min"])).rstrip("0").rstrip("."))))
    F.sort(key=lambda f: (SEV[f[0]], f[1]))
    return {"days": days, "books": seq, "all_books": books, "findings": F, "load": load}


def snap(after, b, snap_pages, day_units):
    """Move a day's stopping point to a chapter end if one is close (within snap_pages and 25% of the day)."""
    if not b["chapters"] or snap_pages <= 0:
        return after
    lim = min(snap_pages, max(1.0, day_units * 0.25))
    ends = [c - 1 for c in b["chapters"] if c - 1 > 0]
    best = None
    for e in ends:
        if abs(e - after) <= lim and e > after - day_units + 0.5 and (best is None or abs(e - after) < abs(best - after)):
            best = e
    return float(best) if best is not None else after


# ---------------------------------------------------------------- rendering
def fmt_item(it):
    if it["kind"] == "audio":
        return "%s %s-%s" % (it["title"], hm(it["from"]), hm(it["to"]))
    a = int(round(it["from"])) + 1
    z = int(round(it["to"]))
    if z < a:
        z = a
    return "%s p. %d-%d (%d pp)" % (it["title"], a, z, z - a + 1)


def hm(x):
    x = int(round(x))
    return "%d:%02d" % (x // 60, x % 60)


def checkpoints(r):
    out = []
    pos = {}
    for x in r["days"]:
        for it in x["items"]:
            pos[it["title"]] = it
        if x["date"].weekday() == 6 and pos:  # Sundays
            out.append((x["date"], "; ".join("%s %s" % (t, ("p. %d" % round(it["to"]) if it["kind"] == "pages" else hm(it["to"]))) for t, it in pos.items() if it["to"] < next(b["units"] for b in r["books"] if b["title"] == t) - 1e-6) or "all current books done"))
    return out


def report(r, m, mode):
    hi = [f for f in r["findings"] if f[0] == "high"]
    out = ["# %s" % m["name"], ""]
    tot_min = sum(x["planned"] for x in r["days"])
    out.append("Start %s, %s mode, %d buffer day(s) before each deadline, catch-up days: %s. Planned reading: %d h %02d min over %d days." % (
        m["start"], mode, m["buffer"], ", ".join(sorted(m["catch"], key=DAYS.index)) or "none", tot_min // 60, tot_min % 60, len(r["days"])))
    out += ["", "**Verdict: %s**" % ("%d book(s) cannot be finished by their deadline - change the plan" % len(hi) if hi else "every deadline is met"), ""]
    out += ["## Books", "", "| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |", "|---|---|---|---|---|---|---|---|"]
    for b in r["all_books"]:
        left = b["units"] - b["done"]
        rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
        avg = left / len(rd) if rd else 0
        spare = (b["deadline"] - b["finish"]).days if b.get("finish") and b["deadline"] else None
        out.append("| %s | %s | %s | %s | %s | %s | %s | %s |" % (
            b["title"], ("%d pp" % round(left)) if b["kind"] == "pages" else hm(left) + " audio", hm(b["need"]) + " h",
            "%s %s" % (b["deadline"], "(%s)" % b["event"] if b["event"] else "") if b["deadline"] else "-",
            b["target"] or "-", b.get("finish") or ("done" if b["need"] <= 0 else "NOT IN TIME"),
            "-" if spare is None else spare, ("%.0f pp" % avg) if b["kind"] == "pages" else hm(avg)))
    out += ["", "## Findings", ""]
    out += ["- **%s %s** %s" % (s.upper(), c, msg) for s, c, msg in r["findings"]] or ["- none"]
    out += ["", "## Day by day", "", "| Date | Day | Minutes | Read | Note |", "|---|---|---|---|---|"]
    events = {}
    for b in r["all_books"]:
        if b["deadline"]:
            events.setdefault(b["deadline"], []).append("DEADLINE %s%s" % (b["title"], " - " + b["event"] if b["event"] else ""))
            if b["target"] and b["target"] != b["deadline"]:
                events.setdefault(b["target"], []).append("finish-by %s" % b["title"])
    for x in r["days"]:
        note = [x["note"]] if x["note"] else []
        if x["note"] == "catch-up day" or (x["planned"] < 0.5 and x["avail"] > 0):
            note = ["catch-up / spare (%d min available)" % x["avail"]] if x["avail"] > 0 else note
        note += events.get(x["date"], [])
        if m["max_session"] and x["planned"] > m["max_session"] + 0.5:
            note.append("2 sessions")
        out.append("| %s | %s | %s | %s | %s |" % (x["date"], x["date"].strftime("%a"), round(x["planned"]) if x["planned"] >= 0.5 else "-",
                                               "; ".join(fmt_item(it) for it in x["items"]) or "-", "; ".join(note)))
    cps = checkpoints(r)
    if cps:
        out += ["", "## Weekly checkpoints (where you should be by Sunday night)", ""]
        out += ["- %s: %s" % (d, s) for d, s in cps]
    out += ["", "## If you fall behind", ""]
    for b in r["books"]:
        if b["kind"] != "pages" or not b["deadline"]:
            continue
        rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
        if not rd:
            continue
        day_pp = sum(it["to"] - it["from"] for x in rd for it in x["items"] if it["title"] == b["title"]) / len(rd)
        spare = [x for x in r["days"] if x["date"] <= b["deadline"] and x["date"] >= rd[0]["date"] and x["avail"] > 0 and x["planned"] < 0.5]
        out.append("- %s: a missed day costs about %d pages. Make them up on %s, or add %d pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at \"%s=<page>\"`." % (
            b["title"], round(day_pp), ("the spare day(s) " + ", ".join(str(x["date"]) for x in spare[:3])) if spare else "no spare day before the deadline", math.ceil(day_pp / 3), b["title"]))
    out.append("")
    return "\n".join(out)


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    def run(cfg, mode="even", **kw):
        m = load(cfg, **kw)
        return plan(m, mode), m
    base = {"start": "2026-11-02", "reader": {"words_per_minute": 250, "words_per_page": 250}, "minutes": 30, "buffer_days": 0}
    r, m = run(dict(base, books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]), "asap")
    t("asap: 30 pages/day at 1 page/min finishes day 2", r["books"][0]["finish"] == datetime.date(2026, 11, 3))
    r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
    t("even: spread over the week (~10 min/day + 3% headroom)", r["books"][0]["finish"] >= datetime.date(2026, 11, 7) and 10 <= r["days"][0]["planned"] <= 10.31)
    r, m = run(dict(base, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
    t("infeasible -> RP01 high", any(f[1] == "RP01" and f[0] == "high" for f in r["findings"]))
    t("RP01 suggests extra minutes", any("+13 min" in f[2] for f in r["findings"] if f[1] == "RP01"))
    r, m = run(dict(base, buffer_days=2, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
    t("buffer days: finish 2 days early", r["books"][0]["finish"] == datetime.date(2026, 11, 6))
    r, m = run(dict(base, catch_up_days=["sun"], books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]))
    t("catch-up Sunday gets no planned reading", r["days"][-1]["planned"] == 0 and r["books"][0]["finish"] == datetime.date(2026, 11, 7))
    r, m = run(dict(base, exceptions=[{"dates": "2026-11-03..2026-11-04", "minutes": 0, "note": "trip"}], books=[{"title": "A", "pages": 50, "deadline": "2026-11-08"}]))
    t("exception range has no reading", all(x["planned"] == 0 for x in r["days"][1:3]))
    two = [{"title": "Late", "pages": 30, "deadline": "2026-11-20"}, {"title": "Soon", "pages": 60, "deadline": "2026-11-04"}]
    r, m = run(dict(base, books=two))
    t("earliest deadline first", r["days"][0]["items"][0]["title"] == "Soon")
    t("tight segment then lighter load", r["load"][0] > 0.6 and r["load"][5] < 0.2)
    r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]), at={"A": 36})
    t("--at resumes from page", abs(sum(x["planned"] for x in r["days"]) - 35) < 0.01)
    r, m = run(dict(base, books=[{"title": "W", "words": 25000, "deadline": "2026-11-08"}]))
    t("word count -> pages via words_per_page", r["all_books"][0]["units"] == 100)
    r, m = run(dict(base, books=[{"title": "H", "pages": 70, "difficulty": 1.5, "deadline": "2026-11-08"}]))
    t("difficulty scales time", abs(r["books"][0]["need"] - 105) < 0.01)
    r, m = run(dict(base, books=[{"title": "Au", "audio_minutes": 300, "speed": 1.5, "deadline": "2026-11-08"}]))
    t("audiobook at 1.5x = 200 min", abs(r["books"][0]["need"] - 200) < 0.01 and any(f[1] == "RP06" for f in r["findings"]))
    r, m = run(dict(base, books=[{"title": "C", "pages": 70, "deadline": "2026-11-08", "chapters": [1, 12, 23, 31, 42, 55]}]))
    ends = [round(it["to"]) for x in r["days"] for it in x["items"]]
    t("chapter snapping stops at chapter ends", 11 in ends and 22 in ends and ends[-1] == 70)
    t("one item per book per day", all(len({it["title"] for it in x["items"]}) == len(x["items"]) for x in r["days"]))
    t("page ranges do not overlap", all(fmt_item(it) for x in r["days"] for it in x["items"]) and
      [int(round(it["from"])) for x in r["days"] for it in x["items"]][1:] == [int(round(it["to"])) for x in r["days"] for it in x["items"]][:-1])
    r, m = run(dict(base, max_session_minutes=20, minutes=60, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
    t("long sessions -> RP03", any(f[1] == "RP03" for f in r["findings"]))
    r, m = run(dict(base, books=[{"title": "A", "pages": 200, "deadline": "2026-11-08"}]))
    t("tight plan -> RP04 or RP01", any(f[1] in ("RP04", "RP01") for f in r["findings"]))
    r, m = run(dict(base, books=[{"title": "A", "pages": 30}, {"title": "B", "pages": 60, "deadline": "2026-11-05"}]))
    t("undated book after dated ones", r["books"][-1]["title"] == "A" and r["books"][-1]["finish"])
    r, m = run(dict(base, books=[{"title": "A", "pages": 20, "start_page": 21}]))
    t("finished book -> RP05", any(f[1] == "RP05" for f in r["findings"]))
    t("pages_per_hour", abs(load(dict(base, reader={"pages_per_hour": 30}, books=[{"title": "P", "pages": 30}]))["books"][0]["unit_min"] - 2) < 1e-9)
    for bad in [dict(base, books=[]), dict(base, books=[{"title": "x"}]), dict(base, minutes={"funday": 3}, books=[{"title": "a", "pages": 3}]),
                dict(base, books=[{"title": "a", "pages": 3, "deadline": "2026-13-01"}])]:
        try:
            run(bad); okk = False
        except InputError:
            okk = True
        t("bad input rejected: %s" % (list(bad.get("minutes", {})) if isinstance(bad.get("minutes"), dict) else (bad["books"][0] if bad["books"] else "no books")), okk)
    r, m = run(dict(base, books=[{"title": "Old", "pages": 30, "deadline": "2026-10-01"}]))
    t("passed deadline -> RP05, planned without deadline", any(f[1] == "RP05" for f in r["findings"]) and r["books"][0]["finish"])
    rep = report(*run(dict(base, books=two)), "even")
    t("report has verdict, day table and catch-up advice", "Verdict" in rep and "| Date |" in rep and "If you fall behind" in rep)
    for name, okk in tests:
        print(("PASS  " if okk else "FAIL  ") + name)
    n = sum(1 for _, okk in tests if okk)
    print("%d/%d passed" % (n, len(tests)))
    return 0 if n == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("plan", nargs="?")
    ap.add_argument("--mode", choices=["even", "asap"], default="even")
    ap.add_argument("--from", dest="start", help="re-plan from this date (YYYY-MM-DD)")
    ap.add_argument("--at", action="append", default=[], help='"Title=page" (or "Title=minute" for audiobooks): where you are now')
    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.plan:
        ap.error("plan JSON required (or --self-test)")
    try:
        try:
            cfg = json.load(open(a.plan, encoding="utf-8"))
        except (OSError, ValueError) as e:
            raise InputError(str(e))
        at = {}
        for s in a.at:
            if "=" not in s:
                raise InputError("--at needs Title=page, got %r" % s)
            k, v = s.rsplit("=", 1)
            try:
                at[k.strip()] = float(v)
            except ValueError:
                raise InputError("--at page must be a number, got %r" % v)
        titles = {b.get("title") for b in cfg.get("books", [])} if isinstance(cfg, dict) else set()
        for k in at:
            if k not in titles:
                raise InputError("--at: no book titled %r" % k)
        m = load(cfg, a.start, at)
        r = plan(m, a.mode)
    except (InputError, KeyError, TypeError, ValueError) as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json:
        out = {"books": [{"title": b["title"], "deadline": str(b["deadline"]) if b["deadline"] else None, "finish": str(b.get("finish")) if b.get("finish") else None,
                          "minutes_needed": round(b["need"])} for b in r["all_books"]],
               "days": [{"date": str(x["date"]), "minutes": round(x["planned"]), "read": [fmt_item(it) for it in x["items"]]} for x in r["days"]],
               "findings": [{"severity": s, "code": c, "message": msg} for s, c, msg in r["findings"]]}
        print(json.dumps(out, indent=2))
    else:
        print(report(r, m, a.mode))
    return 1 if any(f[0] == "high" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningLearning+2
F@import-252f10c83610ebca1a059c0b
0
Family Road Trip Day-by-Day Plan and Packing List (YAML)
Text

Plan a family road trip from start to finish, as YAML: a route overview with estimated distances, a day-by-day driving plan within your daily limit plus a time buffer, rest and dog-walk stops, overnight stays, a fuel or charging plan with backups, a budget with a reserve, a pre-trip car check, packing lists by person and for the car, picnic and snack plans, screen-free games, driver rotation, rules to confirm, and a backup plan for delays and breakdowns. 来源:https://prompts.chat/prompts/cmw37wpc50001jw08q0ua8hu1

1role: Practical, calm road trip planner who helps families and friends turn a road trip idea into a realistic day-by-day driving plan, covering routes and driving limits, rest stops, overnight stays, a fuel or charging plan, a budget, a packing list, car checks, entertainment for passengers, and a backup plan for delays, so the trip stays safe, relaxed, and on budget
2task: >
3 Turn my route idea, my travel dates, my group, my car, my budget, and any bookings I already
4 have into a complete road trip plan as YAML: a summary with the main risks, a route overview,
5 a day-by-day driving plan with realistic driving times and breaks, a rest stop plan, overnight
6 stays, a fuel or charging plan, a budget by category, a pre-trip car check, a packing list by
7 person and by car, a food and snack plan, passenger entertainment, a driver rotation and rest
8 plan, documents and rules to confirm, a delay and breakdown backup plan, and a final
9 departure checklist.
10
...+154 more lines
TravelPlanningYAML+3
F@import-252f10c83610ebca1a059c0b
0