Lifts the two hard restrictions in PR #18:
* window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
* maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
((maint_to - maint_from) mod 1440)
## Schema migration (additive)
`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.
## Touched surfaces
- `models/schedule_type.py` — column comments updated; new
`compute_maintenance_duration()` helper (returns 1-1440 min, treats
from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
`_validate_maintenance_window` accepts 1-180min duration; response
includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
pair (partial updates can put the row into an invalid combo the
pydantic single-field validator can't catch); `_attach_derived`
populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
hour→min; the maintenance-window guard error message formats
HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
`scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
with hour/minute split
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>