"""Deterministic domain types for scheduling / board building. These are pure data classes — no model inference, no network calls. See design/det-vs-inf.md for the deterministic boundary. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, time from enum import Enum from typing import Optional class AppointmentStatus(str, Enum): """Standardized appointment status.""" CONFIRMED = "confirmed" PENDING = "pending" CANCELLED = "cancelled" COMPLETED = "completed" NO_SHOW = "no_show" @dataclass(frozen=True) class Appointment: """A single salon appointment — the core domain object. Fields match what the daily-board (A1) needs to display: time, client, service, staff, status, confirmation flag. """ appointment_id: str start_time: datetime end_time: datetime client_name: str service_name: str staff_name: str status: AppointmentStatus notes: str = "" # Whether the client still needs a confirmation call/message. # Derived at build time from status + last_contact, but stored here # for fixture convenience. needs_confirmation: bool = False def __post_init__(self) -> None: """Validate that end_time is after start_time.""" if self.end_time < self.start_time: raise ValueError( f"end_time ({self.end_time}) must be >= start_time ({self.start_time}) " f"for appointment {self.appointment_id}" ) def __repr__(self) -> str: return ( f"Appointment(id={self.appointment_id!r}, " f"{self.start_time:%H:%M}-{self.end_time:%H:%M}, " f"{self.client_name!r}, {self.status.value})" ) def duration_minutes(self) -> int: """Appointment duration in whole minutes.""" delta = self.end_time - self.start_time return int(delta.total_seconds() // 60) def start_time_only(self) -> time: return self.start_time.time() def end_time_only(self) -> time: return self.end_time.time() @dataclass(frozen=True) class Gap: """An unbooked time slot between two appointments (or day boundary).""" start_time: time end_time: time duration_minutes: int staff_name: str # The appointment immediately before this gap (if any). preceding_appointment_id: Optional[str] = None # The appointment immediately after this gap (if any). following_appointment_id: Optional[str] = None def __repr__(self) -> str: return ( f"Gap({self.start_time:%H:%M}-{self.end_time:%H:%M}, " f"{self.duration_minutes}min, {self.staff_name!r})" ) @dataclass(frozen=True) class DayBoard: """The complete daily board for one staff member or the whole salon. This is the structured output that the daily-board skill presents. All data is deterministic — no model inference. """ date: str # YYYY-MM-DD salon_name: str source: str # "fixtures" | "offline" | "vagaro" | "square" (future) is_offline: bool # True when source is fixtures or offline appointments: list[Appointment] = field(default_factory=list) gaps: list[Gap] = field(default_factory=list) needs_confirmation: list[Appointment] = field(default_factory=list) total_booked_minutes: int = 0 total_gap_minutes: int = 0 def __repr__(self) -> str: return ( f"DayBoard({self.date}, {self.salon_name!r}, " f"{len(self.appointments)} appts, {len(self.gaps)} gaps)" ) @property def utilization_rate(self) -> float: """Fraction of the day that is booked (0.0–1.0). Returns 0.0 when there is no total time to compute against. """ total = self.total_booked_minutes + self.total_gap_minutes if total == 0: return 0.0 return self.total_booked_minutes / total def to_dict(self) -> dict: """Serialize to a plain dict for JSON output.""" return { "date": self.date, "salon_name": self.salon_name, "source": self.source, "is_offline": self.is_offline, "appointments": [ { "id": a.appointment_id, "start": a.start_time.isoformat(), "end": a.end_time.isoformat(), "client": a.client_name, "service": a.service_name, "staff": a.staff_name, "status": a.status.value, "needs_confirmation": a.needs_confirmation, "notes": a.notes, } for a in self.appointments ], "gaps": [ { "start": g.start_time.isoformat(), "end": g.end_time.isoformat(), "duration_minutes": g.duration_minutes, "staff": g.staff_name, "preceding_appointment_id": g.preceding_appointment_id, "following_appointment_id": g.following_appointment_id, } for g in self.gaps ], "needs_confirmation": [ a.appointment_id for a in self.needs_confirmation ], "total_booked_minutes": self.total_booked_minutes, "total_gap_minutes": self.total_gap_minutes, "utilization_rate": round(self.utilization_rate, 4), }