English original
Team Leave and Holiday Coverage Planner
You help team leads, support and operations managers, clinic and shop owners answer the December question: who can be off, when, without leaving the desk empty, and is it fair? Leave spreadsheets show who is away but not whether the remaining people can actually cover the roles (a lead who is also the only tier-2 engineer cannot be both on the same day). You turn the team, the coverage rules and the leave requests into a day-by-day check, decide requests in a stated priority order, offer alternatives instead of a flat no, and flag allowance and fairness problems before they become HR conversations.
Files in this skill
scripts/leave_cover.py- reads a team JSON (people, roles, part-time days, allowances, coverage minimums, holidays, overrides, blackout and peak days) and a leave CSV; checks each workday with a one-person-one-slot matching, finds gaps caused by approved leave, decides open requests (worked-peak-last-year first, then first come first served), suggests the nearest gap-free dates or a shorter trip, and reports a calendar grid, allowance table and findings LV01-LV07 (Python 3.8+ standard library only)references/coverage-rules.md- how to set minimums per role, overrides for quiet days, part-timers, half days, on-call and what the matching doesreferences/fair-leave-policy.md- priority rules, blackout periods, allowance and carry-over, how to say no well, and what each finding code meanstemplates/leave-plan-brief.md- the plan to send to the teamexamples/support-team.json- a 7-person customer support team over Christmas and New Year: leads, tier 2, on-call, a part-timer, holidays, lighter cover on 24 and 31 December, a billing-run blackout and carry-over limitsexamples/support-leave.csv- 3 approved and 7 requested leaves, including a half dayexamples/support-plan.md- the real planner output on those files
Workflow
1. Collect the inputs
Ask for: the period (cover the whole holiday stretch plus a week either side), the team with each person's roles and working days, allowances and days already taken this leave year, public holidays when the team is closed (or the reduced cover if it is open), the minimum cover per role and in total, quieter days with lower minimums, blackout periods (launches, stock-takes, billing runs, audits) and the peak days people compete for. Then the leave: approved, requested (with submission date), training, known parental or medical leave. Ask what the team agreed about priority; if nothing, suggest "worked the peak last year first, then first come first served".
2. Run the planner
python3 scripts/leave_cover.py examples/support-team.json examples/support-leave.csv
python3 scripts/leave_cover.py team.json leave.csv --json
python3 scripts/leave_cover.py --self-test
Exit 0 = approved leave keeps coverage and nobody is over allowance, 1 = approved leave already breaks coverage or exceeds an allowance, 2 = bad input (the message names the line). Without a shell, build the calendar by hand: one row per person, one column per workday, then for each day assign working people to role slots one person per slot and mark shortfalls.
3. Read the result
- Gaps from already-approved leave (LV01) come first: they need a fix now (temporary cover, a swap, an agency or on-call arrangement), not a new rule.
- Allowance problems (LV02): approved leave over the allowance needs HR to confirm carry-over or unpaid days.
- Requests: approve the ones marked approve; for conflicts and blackouts take the alternative to the person, not a refusal (LV04).
- Fairness (LV05) and peak days off: check nobody gets the best days two years running while someone who worked them is refused.
- Fragility: roles with no spare (LV03) and days with zero slack (LV07) need a named backup.
- Use-it-or-lose-it (LV06): people who will lose days should book them in quiet weeks.
4. Adjust and re-run
Try the obvious fixes with the same files: a cross-trained backup (add a role to someone), a lower minimum on a quiet day (override), a swap between two people, or splitting a long trip. Re-run until approved leave has no gaps and every open request has a decision or a concrete alternative.
5. Deliver
Fill templates/leave-plan-brief.md: the rule used, the decision per request with the reason and the alternative, the calendar, the cover arrangements for thin days and the allowance notes. Talk to people whose requests are not approved before publishing the plan.
Example result
On the support team example Omar's approved trip already takes him 3 days over his 25-day allowance (LV02, high: HR must confirm carry-over or unpaid days). Sam worked last Christmas, so his 24 Dec-4 Jan request is decided first and approved; Jonas (21-23 Dec) and Priya's half day and 21-23 Dec are approved too. Ade's 29-31 Dec would leave tier 2 and total cover short on 29 and 30 Dec; the planner offers 16-18 Dec instead. Lena's 30 Dec-8 Jan crosses the 5-6 Jan billing-run blackout, and only 31 Dec can be approved without a gap. Jonas's second trip needs 5 days with 4 left. Lena would lose 10 days to the 5-day carry-over limit, and 21-23 and 29 Dec have no slack at all.
Rules
- Check coverage by role with one person per slot, not by headcount alone.
- Apply one stated priority rule to everyone and show the order; never decide by who asked loudest.
- Always pair a "not this time" with an alternative date, a shorter trip or a swap.
- Treat sick, parental and other protected leave as fixed: never ask people to move it, and keep medical details out of the plan.
- Allowances, carry-over and unpaid leave follow the contract and local law; flag them for HR rather than deciding.
- Share the plan only with the team and managers who need it. FILE:references/coverage-rules.md
Coverage rules
Minimums
- Set a minimum per role (what must be covered: lead/approver, tier 2, on-call, pharmacist, key holder, forklift licence) and an any total (enough hands for the volume).
- Base the numbers on demand, not habit: tickets/calls/orders per day divided by what one person handles, plus the roles that must be present by law or contract.
- Quiet days (24 and 31 December, the week between holidays in many B2B teams) usually justify a lower minimum: use
coverage_overridesfor those dates rather than lowering the whole period. - Days the business is closed go in
holidays: no cover needed, no allowance charged.
One person, one slot
A person who holds several roles still fills only one slot per day. The planner assigns people to slots with a bipartite matching, so it finds the arrangement that works if one exists:
- Maya (lead, tier 2) + Lena (tier 1, tier 2) can cover lead 1 + tier 2 1, but not lead 1 + tier 2 2.
- Shortfalls are reported per role; "any" short means too few people overall.
People
days: part-timers' working weekdays; leave on their non-working days costs no allowance and does not affect cover.- Half days (
half_day=yes) cost 0.5 days of allowance; the planner does not count the person for cover that day (conservative). If a morning-only absence still covers the afternoon shift, say so in the brief. - Training, sick, parental and unpaid leave reduce cover like vacation but do not count against the allowance.
On-call and remote cover
- If someone covers on-call from home while on leave, do not mark them on leave for that role; agree it explicitly and pay it per policy. The planner treats leave as fully off.
- An external or agency backup can be added as a person with only that role and no allowance.
Slack
- Zero-slack days (exactly the minimum working) break with one sick day. Name a backup for each such day before the period starts.
- A role with only minimum + 1 holders cannot survive two absences; plan cross-training in the new year. FILE:references/fair-leave-policy.md
Fair leave decisions
Priority rule (state it before the requests come in)
Common, defensible order:
- Already approved leave stands (unless the person agrees to change it).
- Protected leave (parental, medical, statutory) is fixed.
- For peak days: people who worked the peak last year go first.
- Then first come, first served by submission date.
- Ties: rotate (whoever lost the last tie wins this one). Avoid seniority-only rules for peak days; they feel unfair to newer staff and often lose good people.
Blackout periods
- Keep them short and tied to a real event (launch, billing run, stock-take, audit, school term start for a tutoring business). Announce them early.
- A request that touches a blackout is not automatically refused: the planner offers the nearest gap-free dates or the longest part before the blackout. Exceptions need a named approver.
Allowance and carry-over
- Remaining = allowance - taken before the period - approved - recommended.
- Approved leave over the allowance (LV02 high) is an HR question: carry-over from last year, buying extra days, or unpaid leave.
- Carry-over limits mean unbooked days are lost at year end (LV06): nudge people to book quiet weeks; in some countries statutory minimum leave cannot simply expire, so check local rules.
Saying no well
- Explain the rule and the specific gap ("tier 2 would be one person short on 29 and 30 Dec").
- Offer the alternative the planner found, or a swap with a named colleague, or a shorter trip.
- Decide early (at least 4-6 weeks before the holidays) so people can still book travel.
- Record the decision and the reason; next year's priority depends on it.
Finding codes (leave_cover.py)
| Code | Severity | Meaning |
|---|---|---|
| LV01 | high | approved leave already leaves a role or the total short on a day |
| LV02 | high / medium | approved leave over allowance / request needs more days than left |
| LV03 | medium / low | role held by no more people than the minimum / only one spare |
| LV04 | medium | request conflicts with coverage or a blackout, with the alternative offered |
| LV05 | medium | someone who worked the peak last year is refused while others get peak days |
| LV06 | low | days left above the carry-over limit will be lost |
| LV07 | info | days with zero slack after the plan |
| FILE:templates/leave-plan-brief.md |
Holiday leave plan: ,
Cover rule: <lead 1, tier 2 2 (1 on 24/31 Dec), on-call 1, at least 4 people> Closed: <25 Dec, 28 Dec, 1 Jan> Priority used: <worked the peak last year first, then first come first served> Blackout: <5-6 Jan billing run>
Decisions
| Person | Asked for | Decision | Why | Alternative offered |
|---|---|---|---|---|
| <24 Dec - 4 Jan> | - | |||
| <29-31 Dec> | <tier 2 short 29-30 Dec> | <16-18 Dec, or swap with ...> |
Calendar
<paste the planner grid>
Thin days and backups
| Day | Why thin | Backup |
|---|---|---|
| <21-23 Dec> | <exactly 4 working> | <on-call from home: ...> |
Allowance notes (for HR)
- <Omar: approved trip 3 days over allowance - confirm carry-over / unpaid>
- <Lena: 15 days left, carry-over limit 5 - suggest booking February>
Next steps
- <talk to Ade, Lena, Jonas before publishing>
- <publish the plan on ...; changes by swap request to ...>
- FILE:examples/support-team.json { "team": "Northwind Customer Support", "period": "2026-12-14..2027-01-08", "workdays": ["mon", "tue", "wed", "thu", "fri"], "holidays": ["2026-12-25", "2026-12-28", "2027-01-01"], "coverage": {"lead": 1, "tier2": 2, "oncall": 1, "any": 4}, "coverage_overrides": [ {"dates": "2026-12-24", "coverage": {"tier2": 1, "any": 3}}, {"dates": "2026-12-31", "coverage": {"tier2": 1, "any": 3}} ], "blackout": [ {"dates": "2027-01-05..2027-01-06", "reason": "January billing run, all hands"} ], "peak_days": "2026-12-21..2027-01-01", "carryover_max": 5, "people": [ {"name": "Maya", "roles": ["lead", "tier2"], "allowance": 25, "taken": 17, "worked_peak_last_year": true}, {"name": "Ade", "roles": ["lead", "tier2"], "allowance": 25, "taken": 19}, {"name": "Omar", "roles": ["tier2", "oncall"], "allowance": 25, "taken": 20}, {"name": "Lena", "roles": ["tier1", "tier2"], "allowance": 25, "taken": 10}, {"name": "Jonas", "roles": ["tier1", "oncall"], "allowance": 25, "taken": 18}, {"name": "Sam", "roles": ["tier1", "oncall"], "allowance": 25, "taken": 15, "worked_peak_last_year": true}, {"name": "Priya", "roles": ["tier1"], "days": ["mon", "wed", "thu"], "allowance": 15, "taken": 9} ] } FILE:examples/support-leave.csv person,start,end,type,status,submitted,half_day,notes Maya,2026-12-21,2026-12-24,vacation,approved,2026-09-30,,family visit Omar,2026-12-29,2027-01-08,vacation,approved,2026-10-02,,"flights booked, Lagos" Lena,2026-12-14,2026-12-15,training,approved,2026-11-01,,Zendesk admin course Jonas,2026-12-21,2026-12-23,vacation,requested,2026-10-20,, Ade,2026-12-29,2026-12-31,vacation,requested,2026-10-25,, Sam,2026-12-24,2027-01-04,vacation,requested,2026-11-05,,"skiing, worked last Christmas" Lena,2026-12-30,2027-01-08,vacation,requested,2026-11-12,, Priya,2026-12-16,2026-12-16,vacation,requested,2026-11-20,yes,school nativity play Jonas,2027-01-04,2027-01-08,vacation,requested,2026-11-25,, Priya,2026-12-21,2026-12-23,vacation,requested,2026-11-28,, FILE:examples/support-plan.md
Leave coverage plan: Northwind Customer Support (2026-12-14 to 2027-01-08)
Minimum cover per workday: any 4, lead 1, oncall 1, tier2 2. Holidays (closed): 25 Dec, 28 Dec, 01 Jan.
Verdict: over allowance on approved leave - 4 of 7 open requests can be approved without a gap; 3 need a decision.
Requests (in decision order: worked peak last year first, then first come first served)
| # | Person | Dates | Workdays | Type | Submitted | Decision | Why | Alternative |
|---|---|---|---|---|---|---|---|---|
| 1 | Sam | Thu 24 Dec - Mon 04 Jan | 5 | vacation | 2026-11-05 | approve | - | - |
| 2 | Jonas | Mon 21 Dec - Wed 23 Dec | 3 | vacation | 2026-10-20 | approve | - | - |
| 3 | Ade | Tue 29 Dec - Thu 31 Dec | 3 | vacation | 2026-10-25 | conflict | any/tier2 short on 29 Dec, 30 Dec | Wed 16 Dec to Fri 18 Dec |
| 4 | Lena | Wed 30 Dec - Fri 08 Jan | 7 | vacation | 2026-11-12 | blackout | January billing run, all hands (05 Jan, 06 Jan) | shorten to Thu 31 Dec - Thu 31 Dec (1 workday(s), back before the blackout) |
| 5 | Priya | Wed 16 Dec - Wed 16 Dec (half) | 0.5 | vacation | 2026-11-20 | approve | - | - |
| 6 | Jonas | Mon 04 Jan - Fri 08 Jan | 5 | vacation | 2026-11-25 | over allowance | needs 5 days, 4 left | - |
| 7 | Priya | Mon 21 Dec - Wed 23 Dec | 2 | vacation | 2026-11-28 | approve | - | - |
Calendar (approved + recommended)
D J
11111222222233000000
45678123458901145678
MTWTFMTWTFMTWTFMTWTF
Maya .....VVVVHH...H.....
Ade .........HHxxxH.....
Omar .........HHVVVHVVVVV
Lena TT.......HH.xxHxxxxx
Jonas .....vvv.HH...Hxxxxx
Sam ........vHHvvvHv....
Priya .-h.-v-v.HH-..H.-..-
cover any +++++++++ +++ +++++
cover lead +++++++++ +++ +++++
cover oncall +++++++++ +++ +++++
cover tier2 +++++++++ +++ +++++
V/T/S/P/U/O approved vacation/training/sick/parental/unpaid/other, lowercase = request recommended for approval, h = half day, x = request not approved, H = holiday, - = not a working day, + / ! = role covered / short.
Allowance (days)
| Person | Allowance | Taken before | Approved here | Recommended | Left | Peak days off |
|---|---|---|---|---|---|---|
| Maya | 25 | 17 | 4 | 0 | 4 | 4 |
| Ade | 25 | 19 | 0 | 0 | 6 | 0 |
| Omar | 25 | 20 | 8 | 0 | -3 | 3 |
| Lena | 25 | 10 | 0 | 0 | 15 | 0 |
| Jonas | 25 | 18 | 0 | 3 | 4 | 3 |
| Sam | 25 | 15 | 0 | 5 | 5 | 4 |
| Priya | 15 | 9 | 0 | 2.5 | 3.5 | 2 |
Findings
- HIGH LV02 Omar's approved leave exceeds the allowance by 3 days (20 + 8 taken of 25); confirm carry-over or unpaid leave with HR.
- MEDIUM LV02 Jonas 04 Jan-08 Jan: needs 5 days, 4 left; approve a shorter trip or agree unpaid days
- MEDIUM LV04 Ade 29 Dec-31 Dec: conflict (any/tier2 short on 29 Dec, 30 Dec); offer Wed 16 Dec to Fri 18 Dec
- MEDIUM LV04 Lena 30 Dec-08 Jan: blackout (January billing run, all hands (05 Jan, 06 Jan)); offer shorten to Thu 31 Dec - Thu 31 Dec (1 workday(s), back before the blackout)
- LOW LV03 lead has one spare person (Maya, Ade for a minimum of 1); two absences at once break it.
- LOW LV06 Ade will still have 6 days left (carry-over limit 5); 1 day(s) are lost unless booked.
- LOW LV06 Lena will still have 15 days left (carry-over limit 5); 10 day(s) are lost unless booked.
- INFO LV07 No slack on 4 day(s) (21 Dec, 22 Dec, 23 Dec, 29 Dec): one sick day breaks coverage; line up a backup. FILE:scripts/leave_cover.py
#!/usr/bin/env python3 """Team leave and holiday coverage planner (Python 3.8+ standard library only).
Checks a team's leave requests against minimum staffing rules day by day, decides which requests can be approved without leaving a gap, suggests alternative dates for the rest, and tracks allowances and who gets the peak holiday days.
Team JSON: { "team": "Support Desk", "period": "2026-12-14..2027-01-08", "workdays": ["mon", "tue", "wed", "thu", "fri"], "holidays": ["2026-12-25", "2026-12-28", "2027-01-01"], (office closed, no coverage needed) "coverage": {"lead": 1, "tier2": 2, "oncall": 1, "any": 4}, ("any" = total people working) "coverage_overrides": [{"dates": "2026-12-24", "coverage": {"tier2": 1, "any": 3}}], "blackout": [{"dates": "2027-01-05..2027-01-06", "reason": "billing run"}], "peak_days": "2026-12-21..2027-01-01", "people": [ {"name": "Maya", "roles": ["lead", "tier2"], "allowance": 25, "taken": 17, "worked_peak_last_year": true}, {"name": "Priya", "roles": ["tier1"], "days": ["mon", "wed", "thu"], "allowance": 15, "taken": 9} ] } Each working person fills ONE role slot per day (a lead who is also tier2 counts once), solved as a bipartite matching.
Leave CSV (header required): person,start,end,type,status,submitted,half_day,notes type vacation (counts against allowance) | sick | training | parental | unpaid | other status approved | requested | declined (ignored) half_day yes = half a day of allowance; the person is not counted for coverage that day
Usage: python3 leave_cover.py team.json leave.csv [--json] python3 leave_cover.py --self-test Exit codes: 0 = approved leave keeps coverage and nobody is over allowance, 1 = approved leave already breaks coverage or exceeds an allowance, 2 = bad input. """ import argparse import csv import datetime as dt import json import sys from collections import OrderedDict, defaultdict
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] TYPE_CODE = {"vacation": "V", "sick": "S", "training": "T", "parental": "P", "unpaid": "U", "other": "O"}
class InputError(ValueError): pass
def d(s, what="date"): try: return dt.date.fromisoformat(str(s).strip()) except ValueError: raise InputError("%s %r is not YYYY-MM-DD" % (what, s))
def date_range(spec): spec = str(spec) a, _, b = spec.partition("..") a, b = d(a), d(b or a) if b < a: raise InputError("range %r ends before it starts" % spec) return [a + dt.timedelta(days=i) for i in range((b - a).days + 1)]
----------------------------------------------------------------------------- inputs
def load_team(cfg): if "period" not in cfg or not cfg.get("people"): raise InputError("team JSON needs 'period' and 'people'") t = dict(cfg) t["days"] = date_range(cfg["period"]) t["workdays"] = [w.lower()[:3] for w in cfg.get("workdays", DAYS[:5])] t["holidays"] = {d(h, "holiday") for h in cfg.get("holidays", [])} t["coverage"] = {k: int(v) for k, v in (cfg.get("coverage") or {}).items()} over = {} for o in cfg.get("coverage_overrides", []): for day in date_range(o["dates"]): over[day] = {k: int(v) for k, v in o["coverage"].items()} t["overrides"] = over t["blackout"] = [(set(date_range(b["dates"])), b.get("reason", "blackout")) for b in cfg.get("blackout", [])] t["peak"] = set(date_range(cfg["peak_days"])) if cfg.get("peak_days") else set() people = OrderedDict() for p in cfg["people"]: if "name" not in p: raise InputError("every person needs a name") q = dict(p) q["roles"] = list(p.get("roles", [])) q["days"] = [w.lower()[:3] for w in p.get("days", t["workdays"])] q["allowance"] = float(p.get("allowance", 0)) q["taken"] = float(p.get("taken", 0)) q["carryover_max"] = p.get("carryover_max", cfg.get("carryover_max")) people[p["name"]] = q t["people"] = people roles = {r for p in people.values() for r in p["roles"]} for r in list(t["coverage"]) + [r for o in over.values() for r in o]: if r != "any" and r not in roles: raise InputError("coverage role %r is not held by anyone" % r) return t
def load_leave(path, team): out = [] with open(path, newline="", encoding="utf-8") as f: rows = [r for r in f if r.strip() and not r.lstrip().startswith("#")] for n, row in enumerate(csv.DictReader(rows), 2): name = (row.get("person") or "").strip() if name not in team["people"]: raise InputError("leave line %d: unknown person %r" % (n, name)) typ = (row.get("type") or "vacation").strip().lower() if typ not in TYPE_CODE: raise InputError("leave line %d: type must be one of %s" % (n, ", ".join(TYPE_CODE))) status = (row.get("status") or "requested").strip().lower() if status not in ("approved", "requested", "declined"): raise InputError("leave line %d: status must be approved, requested or declined" % n) start, end = d(row.get("start"), "start"), d(row.get("end") or row.get("start"), "end") if end < start: raise InputError("leave line %d: end before start" % n) out.append({"id": n, "person": name, "start": start, "end": end, "type": typ, "status": status, "submitted": (row.get("submitted") or "").strip() or "9999-12-31", "half": (row.get("half_day") or "").strip().lower() in ("yes", "y", "true", "1"), "notes": (row.get("notes") or "").strip()}) return out
----------------------------------------------------------------------------- core
def is_workday(team, day): return DAYS[day.weekday()] in team["workdays"] and day not in team["holidays"]
def person_works(team, p, day): return is_workday(team, day) and DAYS[day.weekday()] in p["days"]
def leave_days(team, lv): p = team["people"][lv["person"]] days = [] cur = lv["start"] while cur <= lv["end"]: if person_works(team, p, cur): days.append(cur) cur += dt.timedelta(days=1) return days
def charge(team, lv): n = len(leave_days(team, lv)) return 0.5 * n if lv["half"] else float(n)
def need_for(team, day): need = dict(team["coverage"]) need.update(team["overrides"].get(day, {})) return need
def shortfall(team, day, off): """Return {role: missing} for one day given the set of people off.""" if not is_workday(team, day): return {} avail = [p for n, p in team["people"].items() if n not in off and person_works(team, p, day)] need = need_for(team, day) slots = [r for r, k in sorted(need.items()) if r != "any" for _ in range(k)] match = {} # slot index -> person name
def try_assign(pi, seen):
for si, role in enumerate(slots):
if role in avail[pi]["roles"] and si not in seen:
seen.add(si)
if si not in match or try_assign(match[si], seen):
match[si] = pi
return True
return False
for pi in range(len(avail)):
try_assign(pi, set())
short = defaultdict(int)
for si, role in enumerate(slots):
if si not in match:
short[role] += 1
if need.get("any", 0) > len(avail):
short["any"] = need["any"] - len(avail)
return dict(short)
def off_map(team, leaves): off = defaultdict(set) for lv in leaves: for day in leave_days(team, lv): off[day].add(lv["person"]) return off
def gaps(team, leaves): off = off_map(team, leaves) out = OrderedDict() for day in team["days"]: s = shortfall(team, day, off.get(day, set())) if s: out[day] = s return out
def total(g): return sum(sum(v.values()) for v in g.values())
def new_gaps(before, after): out = OrderedDict() for day, s in after.items(): b = before.get(day, {}) worse = {r: k - b.get(r, 0) for r, k in s.items() if k > b.get(r, 0)} if worse: out[day] = worse return out
def blackout_hit(team, lv): days = set(leave_days(team, lv)) for bdays, reason in team["blackout"]: hit = sorted(days & bdays) if hit: return hit, reason return None, None
def suggest(team, current, lv, max_shift=15): """Nearest shift of the same request (same calendar length) that keeps coverage.""" length = (lv["end"] - lv["start"]).days want = len(leave_days(team, lv)) base = gaps(team, current) first, last = team["days"][0], team["days"][-1] for shift in sorted(range(-max_shift, max_shift + 1), key=lambda x: (abs(x), x)): if shift == 0: continue cand = dict(lv, start=lv["start"] + dt.timedelta(days=shift), end=lv["start"] + dt.timedelta(days=shift + length)) if cand["start"] < first or cand["end"] > last: continue if len(leave_days(team, cand)) < want or blackout_hit(team, cand)[0]: continue if not new_gaps(base, gaps(team, current + [cand])): return cand return None
def trimmed(team, current, lv): """Longest part of a blackout-hitting request, before the first blackout day, that keeps coverage.""" hit, _ = blackout_hit(team, lv) days = [x for x in leave_days(team, lv) if x < hit[0]] base = gaps(team, current) best = None for i in range(len(days)): for j in range(len(days) - 1, i - 1, -1): if best and j - i + 1 <= len(leave_days(team, best)): break cand = dict(lv, start=days[i], end=days[j]) if not new_gaps(base, gaps(team, current + [cand])): best = cand break return best
def plan(team, leaves): findings = []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
approved = [l for l in leaves if l["status"] == "approved"]
requests = [l for l in leaves if l["status"] == "requested"]
used = defaultdict(float)
for l in approved:
if l["type"] == "vacation":
used[l["person"]] += charge(team, l)
g_approved = gaps(team, approved)
for day, s in g_approved.items():
add("high", "LV01", "%s %s: approved leave already leaves %s short" % (
day.isoformat(), DAYS[day.weekday()].title(), ", ".join("%s by %d" % (r, k) for r, k in sorted(s.items()))))
def priority(l):
p = team["people"][l["person"]]
touches_peak = bool(set(leave_days(team, l)) & team["peak"])
return (0 if touches_peak and p.get("worked_peak_last_year") else 1, l["submitted"], l["id"])
current = list(approved)
decisions = []
for l in sorted(requests, key=priority):
p = team["people"][l["person"]]
days = leave_days(team, l)
c = charge(team, l) if l["type"] == "vacation" else 0.0
dec = {"leave": l, "workdays": len(days), "charge": c, "decision": "approve", "reason": "", "suggestion": None}
if not days:
dec.update(decision="no workdays", reason="falls on holidays / non-working days only")
elif l["type"] == "vacation" and p["allowance"] and p["taken"] + used[l["person"]] + c > p["allowance"] + 1e-9:
left = p["allowance"] - p["taken"] - used[l["person"]]
dec.update(decision="over allowance", reason="needs %g days, %g left" % (c, max(left, 0)))
else:
hit, reason = blackout_hit(team, l)
if hit:
dec.update(decision="blackout", reason="%s (%s)" % (reason, ", ".join(x.strftime("%d %b") for x in hit)))
else:
ng = new_gaps(gaps(team, current), gaps(team, current + [l]))
if ng:
roles = sorted({r for s in ng.values() for r in s})
dec.update(decision="conflict", reason="%s short on %s" % (
"/".join(roles), ", ".join(x.strftime("%d %b") for x in ng)))
if dec["decision"] == "approve":
current.append(l)
used[l["person"]] += c
elif dec["decision"] in ("conflict", "blackout"):
s = suggest(team, current, l)
if s:
dec["suggestion"] = "%s to %s" % (s["start"].strftime("%a %d %b"), s["end"].strftime("%a %d %b"))
elif dec["decision"] == "blackout":
t = trimmed(team, current, l)
if t:
dec["suggestion"] = "shorten to %s - %s (%d workday(s), back before the blackout)" % (
t["start"].strftime("%a %d %b"), t["end"].strftime("%a %d %b"), len(leave_days(team, t)))
decisions.append(dec)
approved_peak = defaultdict(int)
for l in current:
approved_peak[l["person"]] += len(set(leave_days(team, l)) & team["peak"])
for dec in decisions:
l = dec["leave"]
p = team["people"][l["person"]]
if dec["decision"] != "approve" and p.get("worked_peak_last_year") and set(leave_days(team, l)) & team["peak"]:
lucky = [n for n, k in approved_peak.items() if k and not team["people"][n].get("worked_peak_last_year")]
add("medium", "LV05", "%s worked the peak last year but this request is %s while %s get peak days off; "
"consider swapping." % (l["person"], dec["decision"], ", ".join(sorted(lucky)) or "nobody"))
g_final = gaps(team, current)
allowance = []
for n, p in team["people"].items():
planned_req = sum(dec["charge"] for dec in decisions if dec["leave"]["person"] == n and dec["decision"] == "approve")
appr = used[n] - planned_req
left = p["allowance"] - p["taken"] - used[n] if p["allowance"] else None
allowance.append({"person": n, "allowance": p["allowance"], "taken_before": p["taken"], "approved": appr,
"recommended": planned_req, "left": left, "peak_days_off": approved_peak.get(n, 0)})
if p["allowance"] and p["taken"] + appr > p["allowance"] + 1e-9:
add("high", "LV02", "%s's approved leave exceeds the allowance by %g days (%g + %g taken of %g); "
"confirm carry-over or unpaid leave with HR." % (n, p["taken"] + appr - p["allowance"], p["taken"],
appr, p["allowance"]))
cm = p.get("carryover_max")
if left is not None and cm is not None and left > float(cm):
add("low", "LV06", "%s will still have %g days left (carry-over limit %g); %g day(s) are lost unless booked."
% (n, left, float(cm), left - float(cm)))
for role, k in sorted(team["coverage"].items()):
if role == "any":
continue
holders = [n for n, p in team["people"].items() if role in p["roles"]]
if len(holders) <= k:
add("medium", "LV03", "Only %d people can cover %s (minimum %d): any absence by %s breaks coverage; "
"cross-train a backup." % (len(holders), role, k, ", ".join(holders)))
elif len(holders) == k + 1:
add("low", "LV03", "%s has one spare person (%s for a minimum of %d); two absences at once break it."
% (role, ", ".join(holders), k))
for day, s in g_final.items():
if day not in g_approved:
add("high", "LV01", "%s still short after decisions: %s" % (day, s))
for dec in decisions:
if dec["decision"] in ("conflict", "blackout"):
l = dec["leave"]
add("medium", "LV04", "%s %s-%s: %s (%s)%s" % (
l["person"], l["start"].strftime("%d %b"), l["end"].strftime("%d %b"), dec["decision"], dec["reason"],
("; offer " + dec["suggestion"]) if dec["suggestion"] else "; no gap-free alternative within 15 days"))
elif dec["decision"] == "over allowance":
l = dec["leave"]
add("medium", "LV02", "%s %s-%s: %s; approve a shorter trip or agree unpaid days" % (
l["person"], l["start"].strftime("%d %b"), l["end"].strftime("%d %b"), dec["reason"]))
thin = []
off = off_map(team, current)
for day in team["days"]:
if not is_workday(team, day):
continue
working = [n for n, p in team["people"].items() if n not in off.get(day, set()) and person_works(team, p, day)]
if len(working) == need_for(team, day).get("any", 0):
thin.append(day)
if thin:
add("info", "LV07", "No slack on %d day(s) (%s): one sick day breaks coverage; line up a backup." % (
len(thin), ", ".join(x.strftime("%d %b") for x in thin)))
order = {"high": 0, "medium": 1, "low": 2, "info": 3}
findings.sort(key=lambda f: (order[f["severity"]], f["code"]))
return {"team": team.get("team", "Team"), "period": team["period"], "gaps_approved": g_approved,
"gaps_final": g_final, "decisions": decisions, "allowance": allowance, "findings": findings,
"current": current, "thin": thin}
----------------------------------------------------------------------------- report
def grid(team, leaves_final, all_leaves): days = [x for x in team["days"] if x.weekday() < 5 or DAYS[x.weekday()] in team["workdays"]] final_ids = {l["id"] for l in leaves_final} cell = defaultdict(dict) for l in all_leaves: if l["status"] == "declined": continue for day in leave_days(team, l): if l["id"] in final_ids: c = TYPE_CODE[l["type"]] if l["status"] == "approved" else TYPE_CODE[l["type"]].lower() else: c = "x" if l["half"]: c = "h" if c != "x" else "x" cell[l["person"]][day] = c width = max([len(n) for n in team["people"]] + [len("cover " + r) for r in team["coverage"]]) + 2 months = " " * width + "".join(x.strftime("%b")[0] if i == 0 or x.month != days[i - 1].month else " " for i, x in enumerate(days)) L = [months, " " * width + "".join(str(x.day // 10) for x in days), " " * width + "".join(str(x.day % 10) for x in days), " " * width + "".join(DAYS[x.weekday()][0].upper() for x in days)] for n, p in team["people"].items(): row = [] for x in days: if x in team["holidays"]: row.append("H") elif not person_works(team, p, x): row.append("-") else: row.append(cell[n].get(x, ".")) L.append(n.ljust(width) + "".join(row)) off = off_map(team, leaves_final) for role in sorted(team["coverage"]): row = [] for x in days: if not is_workday(team, x): row.append(" ") continue s = shortfall(team, x, off.get(x, set())) row.append("!" if role in s else "+") L.append(("cover " + role).ljust(width) + "".join(row)) return L
def report(team, leaves, r):
L = ["# Leave coverage plan: %s (%s)" % (r["team"], r["period"].replace("..", " to ")), ""]
need = ", ".join("%s %d" % (k, v) for k, v in sorted(team["coverage"].items()))
L.append("Minimum cover per workday: %s. Holidays (closed): %s." % (
need, ", ".join(x.strftime("%d %b") for x in sorted(team["holidays"])) or "none"))
n_appr = sum(1 for dec in r["decisions"] if dec["decision"] == "approve")
verdict = ("approved leave already breaks coverage" if r["gaps_approved"] else
"over allowance on approved leave" if any(f["code"] == "LV02" and f["severity"] == "high" for f in r["findings"])
else "covered")
L += ["", "Verdict: %s - %d of %d open requests can be approved without a gap; %d need a decision." % (
verdict, n_appr, len(r["decisions"]), len(r["decisions"]) - n_appr), ""]
L += ["## Requests (in decision order: worked peak last year first, then first come first served)", "",
"| # | Person | Dates | Workdays | Type | Submitted | Decision | Why | Alternative |",
"|---|---|---|---|---|---|---|---|---|"]
for i, dec in enumerate(r["decisions"], 1):
l = dec["leave"]
L.append("| %d | %s | %s - %s%s | %s | %s | %s | %s | %s | %s |" % (
i, l["person"], l["start"].strftime("%a %d %b"), l["end"].strftime("%a %d %b"), " (half)" if l["half"] else "",
("%g" % (0.5 * dec["workdays"])) if l["half"] else dec["workdays"], l["type"], l["submitted"][:10],
dec["decision"], dec["reason"] or "-", dec["suggestion"] or "-"))
L += ["", "## Calendar (approved + recommended)", "", ""] + grid(team, r["current"], leaves) + [ "", "V/T/S/P/U/O approved vacation/training/sick/parental/unpaid/other, lowercase = request recommended "
"for approval, h = half day, x = request not approved, H = holiday, - = not a working day, "
"+ / ! = role covered / short.", ""]
L += ["## Allowance (days)", "", "| Person | Allowance | Taken before | Approved here | Recommended | Left | Peak days off |",
"|---|---|---|---|---|---|---|"]
for a in r["allowance"]:
L.append("| %s | %g | %g | %g | %g | %s | %d |" % (a["person"], a["allowance"], a["taken_before"], a["approved"],
a["recommended"], "-" if a["left"] is None else "%g" % a["left"],
a["peak_days_off"]))
L += ["", "## Findings", ""]
if not r["findings"]:
L.append("- none")
for f in r["findings"]:
L.append("- %s %s %s" % (f["severity"].upper(), f["code"], f["message"]))
return "\n".join(L) + "\n"
----------------------------------------------------------------------------- self-test
def self_test(): import os import tempfile cases = []
def ok(name, cond, detail=""):
cases.append((name, bool(cond), detail))
base = {"team": "T", "period": "2026-12-14..2026-12-25", "holidays": ["2026-12-25"], "peak_days": "2026-12-21..2026-12-24",
"coverage": {"lead": 1, "dev": 1, "any": 2},
"people": [{"name": "A", "roles": ["lead", "dev"], "allowance": 10, "taken": 0},
{"name": "B", "roles": ["dev"], "allowance": 10, "taken": 9},
{"name": "C", "roles": ["lead"], "allowance": 10, "taken": 0, "worked_peak_last_year": True},
{"name": "D", "roles": ["dev"], "days": ["mon", "tue"], "allowance": 5, "taken": 0}]}
t = load_team(base)
ok("weekends and holidays are not workdays", not is_workday(t, d("2026-12-19")) and not is_workday(t, d("2026-12-25")))
sf = shortfall(t, d("2026-12-16"), {"B", "C", "D"})
ok("one person fills one slot (A cannot be lead and dev)", sf.get("any") == 1 and sf.get("lead", 0) + sf.get("dev", 0) == 1, sf)
ok("matching finds A=dev, C=lead", shortfall(t, d("2026-12-16"), {"B", "D"}) == {})
ok("part-timer only counts on own days", shortfall(t, d("2026-12-17"), {"A", "B"}) == {"dev": 1, "any": 1}, shortfall(t, d("2026-12-17"), {"A", "B"}))
lv = {"id": 1, "person": "D", "start": d("2026-12-14"), "end": d("2026-12-20"), "type": "vacation",
"status": "requested", "submitted": "2026-11-01", "half": False, "notes": ""}
ok("leave days skip non-working days", len(leave_days(t, lv)) == 2)
ok("half day charges 0.5", charge(t, dict(lv, half=True)) == 1.0)
d0 = tempfile.mkdtemp()
csvp = os.path.join(d0, "l.csv")
with open(csvp, "w") as f:
f.write("person,start,end,type,status,submitted,half_day,notes\n"
"A,2026-12-21,2026-12-22,vacation,approved,2026-10-01,,\n"
"C,2026-12-21,2026-12-21,vacation,requested,2026-11-10,,\n"
"B,2026-12-14,2026-12-16,vacation,requested,2026-10-05,,\n"
"D,2026-12-15,2026-12-15,training,requested,2026-11-01,,\n")
leaves = load_leave(csvp, t)
r = plan(t, leaves)
by = {(dec["leave"]["person"]): dec for dec in r["decisions"]}
ok("C conflicts with A's approved leave (no lead)", by["C"]["decision"] == "conflict", by["C"])
ok("conflict gets an alternative date", by["C"]["suggestion"] is not None, by["C"])
ok("B over allowance (3 days, 1 left)", by["B"]["decision"] == "over allowance", by["B"])
ok("D training approved", by["D"]["decision"] == "approve", by["D"])
ok("worked-peak person decided first", r["decisions"][0]["leave"]["person"] == "C", [x["leave"]["person"] for x in r["decisions"]])
codes = {f["code"] for f in r["findings"]}
ok("LV03 single point of failure for lead", "LV03" in codes, codes)
ok("LV05 fairness flag for C", "LV05" in codes, codes)
ok("no gaps from approved leave", not r["gaps_approved"])
with open(csvp, "a") as f:
f.write("C,2026-12-21,2026-12-21,sick,approved,2026-12-01,,\n")
r2 = plan(t, load_leave(csvp, t))
ok("LV01 gap in approved leave", any(f["code"] == "LV01" and f["severity"] == "high" for f in r2["findings"]))
t2 = load_team(dict(base, blackout=[{"dates": "2026-12-16", "reason": "release"}]))
r3 = plan(t2, load_leave(csvp, t2))
ok("blackout precedes coverage", any(x["decision"] == "blackout" for x in r3["decisions"]) or
all(x["leave"]["person"] != "B" or x["decision"] == "over allowance" for x in r3["decisions"]))
lvb = dict(lv, person="A", start=d("2026-12-16"), end=d("2026-12-16"))
ok("blackout_hit detects overlap", blackout_hit(t2, lvb)[0] == [d("2026-12-16")])
try:
load_team(dict(base, coverage={"qa": 1}))
ok("unknown coverage role rejected", False)
except InputError:
ok("unknown coverage role rejected", True)
rep = report(t, leaves, r)
ok("report has grid and verdict", "Verdict" in rep and "cover lead" in rep)
for name, good, detail in cases:
print("%s %s%s" % ("PASS" if good else "FAIL", name, "" if good else " -> %r" % (detail,)))
passed = sum(1 for _, g, _ in cases if g)
print("%d/%d passed" % (passed, len(cases)))
return 0 if passed == len(cases) else 1
def to_json(r): def conv(o): if isinstance(o, dt.date): return o.isoformat() if isinstance(o, dict): return {(k.isoformat() if isinstance(k, dt.date) else k): conv(v) for k, v in o.items()} if isinstance(o, (list, tuple)): return [conv(x) for x in o] return o return json.dumps(conv(r), indent=2)
def main(argv=None): ap = argparse.ArgumentParser(description="Plan team leave against minimum coverage.") ap.add_argument("team", nargs="?") ap.add_argument("leave", nargs="?") 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.leave): ap.print_usage(sys.stderr) return 2 try: with open(a.team, encoding="utf-8") as f: team = load_team(json.load(f)) leaves = load_leave(a.leave, team) r = plan(team, leaves) except (InputError, OSError, json.JSONDecodeError, KeyError) as e: print("error: %s" % e, file=sys.stderr) return 2 print(to_json(r) if a.json else report(team, leaves, r), end="\n" if a.json else "") bad = r["gaps_approved"] or any(f["code"] == "LV02" and f["severity"] == "high" for f in r["findings"]) return 1 if bad else 0
if name == "main": sys.exit(main())