feat(schedule_type): minute-precision windows + variable maintenance length

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>
This commit is contained in:
h z
2026-05-22 20:18:21 +01:00
parent e5e81d418d
commit 345e0f3a04
8 changed files with 162 additions and 93 deletions

View File

@@ -1,9 +1,17 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance
windows. Agents reference a schedule_type to know when they should be
working, when they can engage in entertainment, and when the system
requires them to surrender control for admin-scheduled special slots.
windows for agents who reference this type. All bounds are stored as
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
sub-hour boundaries are exact.
Maintenance window length is variable (1-180 minutes) and admin-owned;
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
maintenance window was hard-fixed at exactly 1 hour. The additive
migration in `_migrate_schema()` multiplies legacy values by 60 so
existing rows convert transparently.
"""
from sqlalchemy import Column, Integer, String, DateTime
@@ -26,52 +34,26 @@ class ScheduleType(Base):
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
)
work_from = Column(
Integer,
nullable=False,
comment="Work period start hour (0-23, UTC)",
)
# Minutes since UTC midnight, 0-1439 inclusive.
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
work_to = Column(
Integer,
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
entertainment_from = Column(
Integer,
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
comment="Entertainment period end hour (0-23, UTC)",
)
# -----------------------------------------------------------------
# Maintenance window — every agent on this schedule_type must
# surrender work/entertainment slots during this hour. Admin-created
# special slots tied to this schedule_type can only be scheduled
# inside this window. The window is always exactly 1 hour.
#
# Default (when columns are added via additive migration to existing
# rows) is 8:009:00 UTC so deployments stay sane until an operator
# picks proper hours per schedule_type.
# -----------------------------------------------------------------
# Maintenance window — admin-owned, variable length (1-180 min).
# Default 8:009:00 UTC = 480540 minutes for existing rows.
maintenance_from = Column(
Integer,
nullable=False,
server_default="8",
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.",
server_default="480",
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
)
maintenance_to = Column(
Integer,
nullable=False,
server_default="9",
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.",
server_default="540",
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -83,3 +65,19 @@ class ScheduleType(Base):
back_populates="schedule_type",
cascade="all, delete-orphan",
)
# ---------------------------------------------------------------
# Convenience methods used by the API layer + materialiser.
# ---------------------------------------------------------------
def compute_maintenance_duration(self) -> int:
"""Maintenance window length in minutes (handles 23→0 wrap)."""
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
if win_to > win_from:
return start_min < win_to and end_min > win_from
# wrap window crosses midnight: [win_from..1440) [0..win_to)
return start_min < win_to or end_min > win_from