skilldrop-cli 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +4 -1
  2. package/catalogue.json +25 -2
  3. package/model-routing.json +20 -0
  4. package/package.json +1 -1
  5. package/packs/civil-architect/pack.json +60 -0
  6. package/packs/civil-architect/skills/floor-plan-layout/SKILL.md +103 -0
  7. package/packs/civil-architect/skills/floor-plan-layout/evals/eval_queries.json +30 -0
  8. package/packs/civil-architect/skills/floor-plan-layout/evals/evals.json +57 -0
  9. package/packs/civil-architect/skills/floor-plan-layout/evals/files/4/adu-programme.md +12 -0
  10. package/packs/civil-architect/skills/floor-plan-layout/examples/ground-floor.md +65 -0
  11. package/packs/civil-architect/skills/floor-plan-layout/examples/ground-floor.svg +135 -0
  12. package/packs/civil-architect/skills/floor-plan-layout/manifest.json +34 -0
  13. package/packs/civil-architect/skills/floor-plan-layout/scripts/plan_check.py +449 -0
  14. package/packs/civil-architect/skills/floor-plan-layout/templates/plan.json +38 -0
  15. package/packs/event-planner/pack.json +61 -0
  16. package/packs/event-planner/skills/run-of-show/SKILL.md +141 -0
  17. package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
  18. package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
  19. package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
  20. package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
  21. package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
  22. package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
  23. package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
  24. package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
  25. package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
  26. package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
  27. package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
  28. package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
  29. package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
  30. package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
  31. package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
  32. package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
  33. package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
  34. package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
  35. package/packs/event-planner/skills/seating-plan/SKILL.md +152 -0
  36. package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
  37. package/packs/event-planner/skills/seating-plan/evals/evals.json +134 -0
  38. package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
  39. package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
  40. package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
  41. package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
  42. package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
  43. package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
  44. package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
  45. package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
  46. package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
  47. package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
  48. package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
  49. package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
  50. package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
  51. package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
  52. package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
  53. package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
  54. package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
  55. package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
  56. package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
  57. package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
  58. package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
  59. package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
  60. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
  61. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
  62. package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
  63. package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
  64. package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
  65. package/packs/experience-design/pack.json +5 -0
  66. package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
  67. package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
  68. package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
  69. package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
  70. package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
  71. package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
  72. package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
  73. package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
  74. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
  75. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
  76. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
  77. package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
  78. package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
  79. package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
  80. package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
  81. package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
  82. package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
  83. package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
  84. package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
  85. package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
  86. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
  87. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
  88. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
  89. package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
  90. package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
  91. package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
  92. package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
  93. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
  94. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
  95. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
  96. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
  97. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
  98. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
  99. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
  100. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
  101. package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
  102. package/packs/experience-design/skills/locale-check/manifest.json +45 -0
  103. package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
  104. package/packs/operations-manager/pack.json +60 -0
  105. package/packs/operations-manager/skills/shift-roster/SKILL.md +166 -0
  106. package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
  107. package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
  108. package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
  109. package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
  110. package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
  111. package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
  112. package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
  113. package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
  114. package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
  115. package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
  116. package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
  117. package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
  118. package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
  119. package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
  120. package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
  121. package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
  122. package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
  123. package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
  124. package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
  125. package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
  126. package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
  127. package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
  128. package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
  129. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
  130. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
  131. package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
  132. package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
  133. package/packs/operations-manager/skills/shift-roster/templates/roster.json +22 -0
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env python3
2
+ """Check a staff rota against its shifts' needs and the working-time rules, and draw the week. Stdlib only.
3
+
4
+ Reads a roster.json: the staff (skills, weekly maximum and contracted hours, times they
5
+ can't work), the shifts (day or date, start and end, overnight allowed, and how many of each
6
+ skill they need), who is assigned to each shift, and the rules (minimum rest between
7
+ shifts, maximum consecutive days, maximum hours in a day). It fails a rota where
8
+ - a shift is short of a skill it needs (people are matched to needs, one need each)
9
+ - someone is on two shifts that overlap
10
+ - someone works a shift during a time they marked unavailable
11
+ - someone goes over their weekly maximum hours, or the daily maximum
12
+ - someone gets less than the minimum rest between two shifts, across midnight and days
13
+ - someone works more consecutive days than the limit
14
+ - someone fills a role they don't have the skill for, or has none of the skills a shift needs
15
+ and warns where
16
+ - someone's hours are well off their contracted hours
17
+ - weekend or night shifts fall unevenly across the people working that period
18
+ - a shift is short while qualified people are free to take it (they are named)
19
+ Then it can draw the week: a grid of staff rows by day columns, each shift as a block with
20
+ its times and role, failures marked in red, and a coverage row per day.
21
+
22
+ With --summary it also prints, from the rota as checked, each person's shifts, hours, nights,
23
+ weekend days and longest run of days, and each shift's cover against its needs. With
24
+ --compare ORIGINAL.json it lists every assignment the rota adds or removes against the
25
+ original, shift by shift.
26
+
27
+ Usage:
28
+ python3 roster_check.py roster.json [--svg week.svg] [--json] [--summary] [--compare original.json]
29
+ Exit codes: 0 no failures, 1 a failure, 2 bad input.
30
+ """
31
+ import argparse
32
+ import datetime as dt
33
+ import json
34
+ import sys
35
+
36
+ DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
37
+ DEFAULT_RULES = {"min_rest_hours": 11, "max_consecutive_days": 6, "max_hours_per_day": 12}
38
+ ANY = "any"
39
+
40
+
41
+ class InputError(Exception):
42
+ pass
43
+
44
+
45
+ def hhmm(s, what):
46
+ if not isinstance(s, str) or ":" not in s:
47
+ raise InputError(f"{what} must be a time like 07:30")
48
+ h, _, m = s.partition(":")
49
+ if not (h.isdigit() and m.isdigit()):
50
+ raise InputError(f"{what} must be a time like 07:30")
51
+ h, m = int(h), int(m)
52
+ if h == 24 and m == 0:
53
+ return 1440
54
+ if not (0 <= h < 24 and 0 <= m < 60):
55
+ raise InputError(f"{what} is not a time of day: {s}")
56
+ return h * 60 + m
57
+
58
+
59
+ def fmt(mins):
60
+ mins %= 1440
61
+ return "%02d:%02d" % (mins // 60, mins % 60)
62
+
63
+
64
+ def num(v, what, lo=0):
65
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v < lo:
66
+ raise InputError(f"{what} must be a number of at least {lo}")
67
+ return float(v)
68
+
69
+
70
+ class Calendar(object):
71
+ """Turns a day name (with an optional week number) or an ISO date into a day index from
72
+ the roster's first day, so every time is minutes from one origin."""
73
+
74
+ def __init__(self, items):
75
+ dates = [i["date"] for i in items if isinstance(i, dict) and "date" in i]
76
+ days = [i for i in items if isinstance(i, dict) and "day" in i]
77
+ if dates and days:
78
+ raise InputError("use either a day name or a date for every shift and absence, not both")
79
+ self.by_date = bool(dates)
80
+ if self.by_date:
81
+ try:
82
+ self.origin = min(dt.date.fromisoformat(d) for d in dates)
83
+ except (TypeError, ValueError):
84
+ raise InputError("a date must look like 2026-11-09")
85
+ self.origin -= dt.timedelta(days=self.origin.weekday()) # start the grid on a Monday
86
+
87
+ def index(self, item, what):
88
+ if self.by_date:
89
+ if "date" not in item:
90
+ raise InputError(f"{what} needs a date, like the rest of the roster")
91
+ try:
92
+ d = dt.date.fromisoformat(item["date"])
93
+ except (TypeError, ValueError):
94
+ raise InputError(f"{what}: a date must look like 2026-11-09")
95
+ return (d - self.origin).days
96
+ day = item.get("day")
97
+ if day not in DAYS:
98
+ raise InputError(f"{what}: day must be one of {', '.join(DAYS)}")
99
+ week = item.get("week", 1)
100
+ if isinstance(week, bool) or not isinstance(week, int) or week < 1:
101
+ raise InputError(f"{what}: week must be 1, 2, ...")
102
+ return (week - 1) * 7 + DAYS.index(day)
103
+
104
+ def label(self, idx):
105
+ if self.by_date:
106
+ return (self.origin + dt.timedelta(days=idx)).strftime("%a %d %b")
107
+ return DAYS[idx % 7] + (f" wk{idx // 7 + 1}" if idx >= 7 else "")
108
+
109
+
110
+ def load(doc):
111
+ if not isinstance(doc, dict):
112
+ raise InputError("the roster must be a JSON object")
113
+ staff_in, shifts_in = doc.get("staff"), doc.get("shifts")
114
+ if not isinstance(staff_in, list) or not staff_in:
115
+ raise InputError("no staff: the roster needs a staff list")
116
+ if not isinstance(shifts_in, list) or not shifts_in:
117
+ raise InputError("no shifts: the roster needs a shifts list")
118
+ rules = dict(DEFAULT_RULES)
119
+ given = doc.get("rules") or {}
120
+ if not isinstance(given, dict):
121
+ raise InputError("rules must be an object")
122
+ for k, v in given.items():
123
+ if k not in DEFAULT_RULES:
124
+ raise InputError(f"unknown rule {k!r}; known rules are {', '.join(DEFAULT_RULES)}")
125
+ rules[k] = num(v, f"rules.{k}")
126
+ for i, p in enumerate(staff_in):
127
+ if isinstance(p, dict) and not isinstance(p.get("unavailable", []), list):
128
+ raise InputError(f"staff[{i}].unavailable must be a list")
129
+ absences = [a for p in staff_in if isinstance(p, dict) for a in (p.get("unavailable") or []) if isinstance(a, dict)]
130
+ cal = Calendar([s for s in shifts_in if isinstance(s, dict)] + absences)
131
+
132
+ staff, order = {}, []
133
+ for i, p in enumerate(staff_in):
134
+ if not isinstance(p, dict) or not isinstance(p.get("id"), str) or not p["id"]:
135
+ raise InputError(f"staff[{i}] needs an id")
136
+ sid = p["id"]
137
+ if sid in staff:
138
+ raise InputError(f"staff id {sid!r} appears twice")
139
+ skills = p.get("skills", [])
140
+ if not isinstance(skills, list) or not all(isinstance(s, str) for s in skills):
141
+ raise InputError(f"{sid}: skills must be a list of names")
142
+ away = []
143
+ for j, a in enumerate(p.get("unavailable") or []):
144
+ if not isinstance(a, dict):
145
+ raise InputError(f"{sid}: unavailable[{j}] must be an object")
146
+ d = cal.index(a, f"{sid} unavailable[{j}]")
147
+ st = hhmm(a["start"], f"{sid} unavailable[{j}].start") if "start" in a else 0
148
+ en = hhmm(a["end"], f"{sid} unavailable[{j}].end") if "end" in a else 1440
149
+ if en <= st:
150
+ en += 1440
151
+ away.append((d * 1440 + st, d * 1440 + en, a.get("reason", "")))
152
+ staff[sid] = {"id": sid, "name": str(p.get("name") or sid), "skills": set(skills),
153
+ "max_hours": num(p["max_hours"], f"{sid}.max_hours") if "max_hours" in p else None,
154
+ "contracted": num(p["contracted_hours"], f"{sid}.contracted_hours") if "contracted_hours" in p else None,
155
+ "away": away}
156
+ order.append(sid)
157
+
158
+ shifts, sorder = {}, []
159
+ for i, s in enumerate(shifts_in):
160
+ if not isinstance(s, dict) or not isinstance(s.get("id"), str) or not s["id"]:
161
+ raise InputError(f"shifts[{i}] needs an id")
162
+ hid = s["id"]
163
+ if hid in shifts:
164
+ raise InputError(f"shift id {hid!r} appears twice")
165
+ d = cal.index(s, f"shift {hid}")
166
+ st, en = hhmm(s.get("start"), f"{hid}.start"), hhmm(s.get("end"), f"{hid}.end")
167
+ if en <= st:
168
+ en += 1440 # overnight
169
+ needs = s.get("needs") or {}
170
+ if not isinstance(needs, dict):
171
+ raise InputError(f"{hid}: needs must map a skill to a count")
172
+ if "label" in s and not isinstance(s["label"], str):
173
+ raise InputError(f"{hid}: label must be text")
174
+ clean = {}
175
+ for k, v in needs.items():
176
+ if isinstance(v, bool) or not isinstance(v, int) or v < 0:
177
+ raise InputError(f"{hid}: needs.{k} must be a whole number")
178
+ if v:
179
+ clean[k] = v
180
+ shifts[hid] = {"id": hid, "day": d, "start": d * 1440 + st, "end": d * 1440 + en,
181
+ "needs": clean, "label": s.get("label") or hid, "assigned": []}
182
+ sorder.append(hid)
183
+
184
+ assign = doc.get("assignments", {})
185
+ pairs = []
186
+ if assign is None:
187
+ assign = {}
188
+ if isinstance(assign, dict):
189
+ pairs = list(assign.items())
190
+ elif isinstance(assign, list):
191
+ for j, a in enumerate(assign):
192
+ if not isinstance(a, dict) or "shift" not in a:
193
+ raise InputError(f"assignments[{j}] needs a shift")
194
+ pairs.append((a["shift"], a.get("staff", [])))
195
+ else:
196
+ raise InputError("assignments must map a shift id to its staff")
197
+ for s in shifts_in:
198
+ if isinstance(s, dict) and s.get("assigned"):
199
+ pairs.append((s["id"], s["assigned"]))
200
+ for hid, people in pairs:
201
+ if not isinstance(hid, str) or hid not in shifts:
202
+ raise InputError(f"assignments name shift {hid!r}, which is not in the shifts list")
203
+ if isinstance(people, (str, dict)):
204
+ people = [people]
205
+ if not isinstance(people, list):
206
+ raise InputError(f"assignments.{hid} must be a list of staff ids")
207
+ for x in people:
208
+ sid, role = (x.get("id"), x.get("as")) if isinstance(x, dict) else (x, None)
209
+ if role is not None and not isinstance(role, str):
210
+ raise InputError(f"shift {hid}: a role given with \"as\" must be a skill name")
211
+ if not isinstance(sid, str) or sid not in staff:
212
+ raise InputError(f"shift {hid} names staff {sid!r}, who is not in the staff list")
213
+ if any(a[0] == sid for a in shifts[hid]["assigned"]):
214
+ raise InputError(f"{sid} is assigned to {hid} twice")
215
+ shifts[hid]["assigned"].append((sid, role))
216
+ return {"rules": rules, "staff": staff, "order": order, "shifts": shifts, "sorder": sorder, "cal": cal,
217
+ "ndays": max(7, 7 * ((max(s["day"] for s in shifts.values()) // 7) + 1))}
218
+
219
+
220
+ def hours(shift):
221
+ return (shift["end"] - shift["start"]) / 60.0
222
+
223
+
224
+ def cover(shift, staff):
225
+ """Match assigned people to the shift's needs, one need each, preferring the role a
226
+ person was assigned as. Returns (filled per skill, short per skill, {sid: skill or None},
227
+ [role errors])."""
228
+ slots = [k for k, n in sorted(shift["needs"].items()) for _ in range(n)]
229
+ people = shift["assigned"]
230
+ errors = []
231
+
232
+ def can(sid, role, skill):
233
+ if role is not None:
234
+ return role == skill
235
+ return skill == ANY or skill in staff[sid]["skills"]
236
+
237
+ for sid, role in people:
238
+ if role is not None and role != ANY and role not in staff[sid]["skills"]:
239
+ errors.append(f"{staff[sid]['name']} is on {shift['label']} as {role} but doesn't have that skill")
240
+ match = {} # slot index -> person index
241
+
242
+ def augment(pi, seen):
243
+ sid, role = people[pi]
244
+ if role is not None and role != ANY and role not in staff[sid]["skills"]:
245
+ return False
246
+ for si, skill in enumerate(slots):
247
+ if si in seen or not can(sid, role, skill):
248
+ continue
249
+ seen.add(si)
250
+ if si not in match or augment(match[si], seen):
251
+ match[si] = pi
252
+ return True
253
+ return False
254
+
255
+ for pi in range(len(people)):
256
+ augment(pi, set())
257
+ filled, short = {}, {}
258
+ for si, skill in enumerate(slots):
259
+ if si in match:
260
+ filled[skill] = filled.get(skill, 0) + 1
261
+ else:
262
+ short[skill] = short.get(skill, 0) + 1
263
+ role_of = {people[pi][0]: slots[si] for si, pi in match.items()}
264
+ for sid, role in people:
265
+ if sid not in role_of and role is None and shift["needs"] and not (
266
+ staff[sid]["skills"] & set(shift["needs"]) or ANY in shift["needs"]):
267
+ errors.append(f"{staff[sid]['name']} is on {shift['label']} but has none of the skills it needs "
268
+ f"({', '.join(sorted(shift['needs']))})")
269
+ role_of.setdefault(sid, None)
270
+ return filled, short, role_of, errors
271
+
272
+
273
+ def overlaps(a0, a1, b0, b1):
274
+ return a0 < b1 and b0 < a1
275
+
276
+
277
+ def is_night(shift):
278
+ return shift["end"] - shift["day"] * 1440 > 1440 or (shift["start"] % 1440) >= 20 * 60
279
+
280
+
281
+ def is_weekend(shift):
282
+ return shift["day"] % 7 in (5, 6)
283
+
284
+
285
+ def check(r):
286
+ staff, shifts, rules = r["staff"], r["shifts"], r["rules"]
287
+ fails, warns = [], []
288
+ bad_shift, bad_cell = set(), set() # shift ids short; (sid, shift id) involved in a failure
289
+ roles, coverage = {}, {}
290
+ for hid in r["sorder"]:
291
+ sh = shifts[hid]
292
+ filled, short, role_of, errs = cover(sh, staff)
293
+ roles[hid] = role_of
294
+ coverage[hid] = {"needs": sh["needs"], "filled": filled, "short": short}
295
+ for e in errs:
296
+ fails.append(e)
297
+ for sid, _ in sh["assigned"]:
298
+ if staff[sid]["name"] in e:
299
+ bad_cell.add((sid, hid))
300
+ if short:
301
+ bad_shift.add(hid)
302
+ fails.append(f"{sh['label']} ({r['cal'].label(sh['day'])} {fmt(sh['start'])}–{fmt(sh['end'])}) is short "
303
+ + ", ".join(f"{n} {k}" for k, n in sorted(short.items())))
304
+
305
+ worked = {sid: sorted((shifts[h] for h in r["sorder"] if any(a[0] == sid for a in shifts[h]["assigned"])),
306
+ key=lambda s: s["start"]) for sid in r["order"]}
307
+ per = {}
308
+ for sid in r["order"]:
309
+ p, mine = staff[sid], worked[sid]
310
+ name = p["name"]
311
+ for a, b in zip(mine, mine[1:]):
312
+ if overlaps(a["start"], a["end"], b["start"], b["end"]):
313
+ fails.append(f"{name} is on {a['label']} and {b['label']}, which overlap")
314
+ bad_cell |= {(sid, a["id"]), (sid, b["id"])}
315
+ else:
316
+ rest = (b["start"] - a["end"]) / 60.0
317
+ if rest < rules["min_rest_hours"]:
318
+ fails.append(f"{name} gets {rest:g} h rest between {a['label']} (ends {r['cal'].label(a['end'] // 1440)} "
319
+ f"{fmt(a['end'])}) and {b['label']}, under the {rules['min_rest_hours']:g} h minimum")
320
+ bad_cell |= {(sid, a["id"]), (sid, b["id"])}
321
+ for sh in mine:
322
+ for a0, a1, why in p["away"]:
323
+ if overlaps(sh["start"], sh["end"], a0, a1):
324
+ fails.append(f"{name} is on {sh['label']} but is unavailable then" + (f" ({why})" if why else ""))
325
+ bad_cell.add((sid, sh["id"]))
326
+ break
327
+ weeks = {}
328
+ for sh in mine:
329
+ weeks.setdefault(sh["day"] // 7, []).append(sh)
330
+ for w, lst in sorted(weeks.items()):
331
+ total = sum(hours(s) for s in lst)
332
+ if p["max_hours"] is not None and total > p["max_hours"] + 1e-9:
333
+ fails.append(f"{name} works {total:g} h in week {w + 1}, over their {p['max_hours']:g} h maximum")
334
+ bad_cell |= {(sid, s["id"]) for s in lst}
335
+ days = {}
336
+ for sh in mine:
337
+ days.setdefault(sh["day"], []).append(sh)
338
+ for d, lst in sorted(days.items()):
339
+ total = sum(hours(s) for s in lst)
340
+ if total > rules["max_hours_per_day"] + 1e-9:
341
+ fails.append(f"{name} works {total:g} h on {r['cal'].label(d)}, over the {rules['max_hours_per_day']:g} h daily maximum")
342
+ bad_cell |= {(sid, s["id"]) for s in lst}
343
+ run, best, prev, run_days = 0, (0, []), None, []
344
+ for d in sorted(days):
345
+ run = run + 1 if prev is not None and d == prev + 1 else 1
346
+ run_days = run_days + [d] if run > 1 else [d]
347
+ if run > best[0]:
348
+ best = (run, list(run_days))
349
+ prev = d
350
+ if best[0] > rules["max_consecutive_days"]:
351
+ fails.append(f"{name} works {best[0]} days in a row ({r['cal'].label(best[1][0])} to {r['cal'].label(best[1][-1])}), "
352
+ f"over the {rules['max_consecutive_days']:g}-day limit")
353
+ bad_cell |= {(sid, s["id"]) for d in best[1] for s in days[d]}
354
+ total = sum(hours(s) for s in mine)
355
+ nweeks = max(1, r["ndays"] // 7)
356
+ per[sid] = {"name": name, "hours": total, "shifts": len(mine),
357
+ "weekend": sum(1 for s in mine if is_weekend(s)), "night": sum(1 for s in mine if is_night(s)),
358
+ "weekend_days": len({s["day"] for s in mine if is_weekend(s)}),
359
+ "days_in_a_row": best[0], "worked": [s["id"] for s in mine],
360
+ "contracted": p["contracted"], "max": p["max_hours"]}
361
+ if p["contracted"] is not None:
362
+ want = p["contracted"] * nweeks
363
+ if abs(total - want) > max(4.0, 0.2 * want):
364
+ warns.append(f"{name} works {total:g} h against {want:g} h contracted")
365
+
366
+ for kind, test in (("weekend", is_weekend), ("night", is_night)):
367
+ if not any(test(s) for s in shifts.values()):
368
+ continue
369
+ # among the people who could work those shifts: anyone with a skill one of them needs
370
+ needed = {k for s in shifts.values() if test(s) for k in s["needs"]}
371
+ pool = [sid for sid in r["order"] if ANY in needed or staff[sid]["skills"] & needed]
372
+ counts = {sid: per[sid][kind] for sid in pool}
373
+ if len(counts) > 1 and max(counts.values()) - min(counts.values()) >= 2:
374
+ hi = max(counts, key=counts.get)
375
+ lo = min(counts, key=counts.get)
376
+ warns.append(f"{kind} shifts fall unevenly: {staff[hi]['name']} has {counts[hi]}, "
377
+ f"{staff[lo]['name']} has {counts[lo]}")
378
+
379
+ for hid in r["sorder"]:
380
+ short = coverage[hid]["short"]
381
+ if not short:
382
+ continue
383
+ sh = shifts[hid]
384
+ for skill in sorted(short):
385
+ free = []
386
+ for sid in r["order"]:
387
+ p = staff[sid]
388
+ if any(a[0] == sid for a in sh["assigned"]):
389
+ continue
390
+ if skill != ANY and skill not in p["skills"]:
391
+ continue
392
+ if any(overlaps(sh["start"], sh["end"], a0, a1) for a0, a1, _ in p["away"]):
393
+ continue
394
+ clash = False
395
+ for o in worked[sid]:
396
+ gap = max(o["start"] - sh["end"], sh["start"] - o["end"]) / 60.0
397
+ if overlaps(o["start"], o["end"], sh["start"], sh["end"]) or gap < rules["min_rest_hours"]:
398
+ clash = True
399
+ break
400
+ week = sum(hours(o) for o in worked[sid] if o["day"] // 7 == sh["day"] // 7)
401
+ if clash or (p["max_hours"] is not None and week + hours(sh) > p["max_hours"] + 1e-9):
402
+ continue
403
+ free.append(p["name"])
404
+ if free:
405
+ warns.append(f"{sh['label']} is short {short[skill]} {skill}, and qualified people are free to take it: "
406
+ + ", ".join(free))
407
+ return {"fails": fails, "warns": warns, "coverage": coverage, "people": per, "roles": roles,
408
+ "bad_shift": bad_shift, "bad_cell": bad_cell}
409
+
410
+
411
+ def report(r, res):
412
+ out = []
413
+ sh, cal = r["shifts"], r["cal"]
414
+ out.append("Coverage")
415
+ for hid in r["sorder"]:
416
+ s, c = sh[hid], res["coverage"][hid]
417
+ need = ", ".join(f"{k} {c['filled'].get(k, 0)}/{n}" for k, n in sorted(c["needs"].items())) or "no stated needs"
418
+ who = ", ".join(r["staff"][sid]["name"] for sid, _ in s["assigned"]) or "nobody"
419
+ out.append(f" {'SHORT' if c['short'] else 'ok '} {s['label']:<14} {cal.label(s['day']):<10} "
420
+ f"{fmt(s['start'])}–{fmt(s['end'])} {need} [{who}]")
421
+ out.append("")
422
+ out.append("Hours")
423
+ for sid in r["order"]:
424
+ p = res["people"][sid]
425
+ extra = []
426
+ if p["contracted"] is not None:
427
+ extra.append(f"contracted {p['contracted']:g}")
428
+ if p["max"] is not None:
429
+ extra.append(f"max {p['max']:g}")
430
+ out.append(f" {p['name']:<16} {p['hours']:>5g} h {p['shifts']} shifts, {p['weekend']} weekend, "
431
+ f"{p['night']} night" + (f" ({', '.join(extra)})" if extra else ""))
432
+ out.append("")
433
+ for f in res["fails"]:
434
+ out.append("FAIL " + f)
435
+ for w in res["warns"]:
436
+ out.append("WARN " + w)
437
+ out.append(f"{len(res['fails'])} failure(s), {len(res['warns'])} warning(s).")
438
+ return "\n".join(out) + "\n"
439
+
440
+
441
+ def shift_text(r, sh, sid=None, res=None):
442
+ role = res["roles"].get(sh["id"], {}).get(sid) if (res and sid) else None
443
+ return (f"{sh['label']} {r['cal'].label(sh['day'])} {fmt(sh['start'])}–{fmt(sh['end'])}"
444
+ + (f" as {role}" if role and role != ANY else ""))
445
+
446
+
447
+ def summary(r, res):
448
+ """What the final rota says, per person and per shift: the facts a reply quotes, so its
449
+ notes can't drift from the schedule."""
450
+ people = []
451
+ for sid in r["order"]:
452
+ p = res["people"][sid]
453
+ people.append({"id": sid, "name": p["name"], "hours": p["hours"], "shifts": p["shifts"],
454
+ "nights": p["night"], "weekend_days": p["weekend_days"],
455
+ "days_in_a_row": p["days_in_a_row"], "contracted": p["contracted"], "max": p["max"],
456
+ "on": [shift_text(r, r["shifts"][h], sid, res) for h in p["worked"]]})
457
+ shifts = []
458
+ for hid in r["sorder"]:
459
+ sh, c = r["shifts"][hid], res["coverage"][hid]
460
+ shifts.append({"id": hid, "shift": shift_text(r, sh),
461
+ "cover": {k: [c["filled"].get(k, 0), n] for k, n in sorted(c["needs"].items())},
462
+ "short": c["short"], "who": [r["staff"][sid]["name"] for sid, _ in sh["assigned"]]})
463
+ return {"people": people, "shifts": shifts}
464
+
465
+
466
+ def summary_text(sm):
467
+ out = ["Per person"]
468
+ for p in sm["people"]:
469
+ bits = [f"{p['hours']:g} h", f"{p['shifts']} shifts", f"{p['nights']} nights",
470
+ f"{p['weekend_days']} weekend days", f"{p['days_in_a_row']} days in a row at most"]
471
+ if p["contracted"] is not None:
472
+ bits.append(f"contracted {p['contracted']:g} h")
473
+ out.append(f" {p['name']}: " + ", ".join(bits))
474
+ for t in p["on"]:
475
+ out.append(f" {t}")
476
+ out.append("")
477
+ out.append("Per shift")
478
+ for s in sm["shifts"]:
479
+ cover = ", ".join(f"{k} {a}/{b}" for k, (a, b) in s["cover"].items()) or "no stated needs"
480
+ out.append(f" {'SHORT' if s['short'] else 'ok '} {s['shift']}: {cover} [{', '.join(s['who']) or 'nobody'}]")
481
+ return "\n".join(out) + "\n"
482
+
483
+
484
+ def compare(r, orig):
485
+ """Every assignment the final rota adds or removes against the original, by shift."""
486
+ changes = []
487
+ for hid in r["sorder"] + [h for h in orig["sorder"] if h not in r["shifts"]]:
488
+ now = {sid for sid, _ in r["shifts"][hid]["assigned"]} if hid in r["shifts"] else set()
489
+ was = {sid for sid, _ in orig["shifts"][hid]["assigned"]} if hid in orig["shifts"] else set()
490
+ if now == was:
491
+ continue
492
+ sh = r["shifts"].get(hid) or orig["shifts"][hid]
493
+ name = lambda sid: (r["staff"].get(sid) or orig["staff"].get(sid) or {"name": sid})["name"]
494
+ changes.append({"shift": hid, "when": shift_text(r if hid in r["shifts"] else orig, sh),
495
+ "added": sorted(name(s) for s in now - was), "removed": sorted(name(s) for s in was - now)})
496
+ return changes
497
+
498
+
499
+ def compare_text(changes):
500
+ if not changes:
501
+ return "No assignment changed against the original.\n"
502
+ out = [f"{len(changes)} shift(s) changed against the original"]
503
+ for c in changes:
504
+ bits = ([f"added {', '.join(c['added'])}"] if c["added"] else []) + \
505
+ ([f"removed {', '.join(c['removed'])}"] if c["removed"] else [])
506
+ out.append(f" {c['when']}: " + "; ".join(bits))
507
+ return "\n".join(out) + "\n"
508
+
509
+
510
+ def esc(s):
511
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
512
+
513
+
514
+ def svg(r, res):
515
+ cal, sh, staff = r["cal"], r["shifts"], r["staff"]
516
+ ndays = r["ndays"]
517
+ name_w, col_w, head_h, blk_h, pad = 150, 112, 76, 30, 6
518
+ cells = {}
519
+ for hid in r["sorder"]:
520
+ for sid, _ in sh[hid]["assigned"]:
521
+ cells.setdefault((sid, sh[hid]["day"]), []).append(hid)
522
+ row_h = {sid: max([len(v) for (s, _), v in cells.items() if s == sid] + [1]) * (blk_h + 4) + 2 * pad
523
+ for sid in r["order"]}
524
+ per_day = {}
525
+ for hid in r["sorder"]:
526
+ per_day.setdefault(sh[hid]["day"], []).append(hid)
527
+ cov_h = max([len(v) for v in per_day.values()] + [1]) * 18 + 2 * pad + 18
528
+ width = name_w + ndays * col_w + 20
529
+ height = head_h + sum(row_h.values()) + cov_h + 40
530
+ o = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
531
+ f'font-family="Helvetica, Arial, sans-serif" font-size="11">',
532
+ f'<rect width="{width}" height="{height}" fill="#ffffff"/>',
533
+ f'<text x="10" y="22" font-size="15" font-weight="bold">Rota</text>',
534
+ f'<text x="10" y="40" fill="#555">{len(res["fails"])} failure(s), {len(res["warns"])} warning(s). '
535
+ f'Red marks a shift or a person the check failed.</text>']
536
+ for d in range(ndays):
537
+ x = name_w + d * col_w
538
+ fill = "#f4f4f6" if d % 7 in (5, 6) else "#ffffff"
539
+ o.append(f'<rect x="{x}" y="{head_h - 16}" width="{col_w}" height="{height - head_h - 24}" fill="{fill}" stroke="#ddd"/>')
540
+ o.append(f'<text x="{x + col_w / 2}" y="{head_h - 4}" text-anchor="middle" font-weight="bold">{esc(cal.label(d))}</text>')
541
+ y = head_h
542
+ for sid in r["order"]:
543
+ h = row_h[sid]
544
+ p = res["people"][sid]
545
+ o.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="#ccc"/>')
546
+ o.append(f'<text x="10" y="{y + 18}" font-weight="bold">{esc(p["name"])}</text>')
547
+ o.append(f'<text x="10" y="{y + 32}" fill="#555">{p["hours"]:g} h'
548
+ + (f' / {p["contracted"]:g} contracted' if p["contracted"] is not None else "") + '</text>')
549
+ for (s2, d), hids in sorted(cells.items()):
550
+ if s2 != sid:
551
+ continue
552
+ for k, hid in enumerate(hids):
553
+ s = sh[hid]
554
+ bx, by = name_w + d * col_w + 4, y + pad + k * (blk_h + 4)
555
+ bad = (sid, hid) in res["bad_cell"]
556
+ role = res["roles"][hid].get(sid)
557
+ o.append(f'<rect x="{bx}" y="{by}" width="{col_w - 8}" height="{blk_h}" rx="4" '
558
+ f'fill="{"#fde2e2" if bad else "#e6f0fb"}" stroke="{"#c62828" if bad else "#4a7fc1"}" '
559
+ f'stroke-width="{2 if bad else 1}"/>')
560
+ o.append(f'<text x="{bx + 5}" y="{by + 12}">{fmt(s["start"])}–{fmt(s["end"])}'
561
+ f'{" +1" if s["end"] - s["day"] * 1440 > 1440 else ""}</text>')
562
+ both = f"{role} · {s['label']}" if role else s["label"]
563
+ line = both if len(both) <= 17 else (role or s["label"])
564
+ o.append(f'<text x="{bx + 5}" y="{by + 25}" fill="#555">{esc(line[:17])}</text>')
565
+ y += h
566
+ o.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="#999"/>')
567
+ o.append(f'<text x="10" y="{y + 18}" font-weight="bold">Coverage</text>')
568
+ for d, hids in per_day.items():
569
+ for k, hid in enumerate(hids):
570
+ c = res["coverage"][hid]
571
+ need, got = sum(c["needs"].values()), sum(c["filled"].values())
572
+ short = bool(c["short"])
573
+ o.append(f'<text x="{name_w + d * col_w + 6}" y="{y + 18 + k * 18}" fill="{"#c62828" if short else "#2e7d32"}" '
574
+ f'font-weight="{"bold" if short else "normal"}">{esc(sh[hid]["label"])[:13]} {got}/{need}</text>')
575
+ o.append("</svg>")
576
+ return "\n".join(o) + "\n"
577
+
578
+
579
+ def jsonable(r, res):
580
+ cal = r["cal"]
581
+ return {"fails": res["fails"], "warns": res["warns"],
582
+ "coverage": {h: dict(res["coverage"][h], day=cal.label(r["shifts"][h]["day"])) for h in r["sorder"]},
583
+ "people": res["people"]}
584
+
585
+
586
+ def main(argv=None):
587
+ ap = argparse.ArgumentParser(description="Check a staff rota against its shifts' needs and the working-time rules.")
588
+ ap.add_argument("roster")
589
+ ap.add_argument("--svg", help="draw the week grid here")
590
+ ap.add_argument("--json", action="store_true", help="print the result as JSON")
591
+ ap.add_argument("--summary", action="store_true",
592
+ help="also print per person (shifts, hours, nights, weekend days, days in a row) and per shift (cover against need)")
593
+ ap.add_argument("--compare", metavar="ORIGINAL", help="also list every assignment added or removed against this original roster")
594
+ args = ap.parse_args(argv)
595
+ paths = [args.roster] + ([args.compare] if args.compare else [])
596
+ loaded = []
597
+ for path in paths:
598
+ try:
599
+ with open(path, encoding="utf-8-sig") as f:
600
+ loaded.append(load(json.load(f)))
601
+ except (OSError, ValueError, UnicodeDecodeError) as e:
602
+ print(f"FAIL cannot read {path}: {e}")
603
+ return 2
604
+ except InputError as e:
605
+ print(f"FAIL {path}: {e}" if path != args.roster else f"FAIL {e}")
606
+ return 2
607
+ r = loaded[0]
608
+ res = check(r)
609
+ sm = summary(r, res) if args.summary else None
610
+ changes = compare(r, loaded[1]) if args.compare else None
611
+ if args.json:
612
+ out = jsonable(r, res)
613
+ if sm is not None:
614
+ out["summary"] = sm
615
+ if changes is not None:
616
+ out["changes"] = changes
617
+ print(json.dumps(out, indent=1))
618
+ else:
619
+ print(report(r, res), end="")
620
+ if sm is not None:
621
+ print("\n" + summary_text(sm), end="")
622
+ if changes is not None:
623
+ print("\n" + compare_text(changes), end="")
624
+ if args.svg:
625
+ with open(args.svg, "w", encoding="utf-8", newline="\n") as f:
626
+ f.write(svg(r, res))
627
+ if not args.json:
628
+ print(f"\nWrote {args.svg}")
629
+ return 1 if res["fails"] else 0
630
+
631
+
632
+ if __name__ == "__main__":
633
+ sys.exit(main())
@@ -0,0 +1,22 @@
1
+ {
2
+ "$comment": "One week at a small shop. Days are Mon..Sun (add \"week\": 2 for a fortnight), or use \"date\": \"2026-11-09\" everywhere instead. A shift that ends before it starts runs past midnight. needs maps a skill to how many people with it the shift must have; each person fills one need. \"any\" matches everyone. An assignment can be a plain id, or {\"id\": ..., \"as\": skill} to fix the role. rules left out default to 11 h rest, 6 days in a row and 12 h a day.",
3
+ "rules": { "min_rest_hours": 11, "max_consecutive_days": 6, "max_hours_per_day": 12 },
4
+ "staff": [
5
+ { "id": "amira", "name": "Amira", "skills": ["keyholder", "till"], "max_hours": 40, "contracted_hours": 37.5 },
6
+ { "id": "ben", "name": "Ben", "skills": ["till"], "max_hours": 30, "contracted_hours": 24,
7
+ "unavailable": [ { "day": "Wed", "reason": "college" } ] },
8
+ { "id": "chloe", "name": "Chloe", "skills": ["till", "stock"], "max_hours": 40, "contracted_hours": 30,
9
+ "unavailable": [ { "day": "Sat", "start": "16:00", "end": "23:59", "reason": "childcare" } ] },
10
+ { "id": "dev", "name": "Dev", "skills": ["keyholder", "stock"], "max_hours": 40, "contracted_hours": 37.5 }
11
+ ],
12
+ "shifts": [
13
+ { "id": "mon-early", "day": "Mon", "start": "07:00", "end": "15:00", "needs": { "keyholder": 1, "till": 1 } },
14
+ { "id": "mon-late", "day": "Mon", "start": "14:00", "end": "22:00", "needs": { "keyholder": 1, "till": 1 } },
15
+ { "id": "tue-night", "day": "Tue", "start": "22:00", "end": "06:00", "label": "Tue delivery", "needs": { "stock": 2 } }
16
+ ],
17
+ "assignments": {
18
+ "mon-early": ["amira", "ben"],
19
+ "mon-late": ["dev", { "id": "chloe", "as": "till" }],
20
+ "tue-night": ["chloe", "dev"]
21
+ }
22
+ }